feat(crud): phase 5c full user and group CRUD #16
No files matched your search
@@ -4,6 +4,10 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added (full user and group CRUD, phase 5c: DESIGN.md section 12.5)
|
||||
- **Edit User** also changes the username (validated, unique) and the other attributes (a JSON object; reserved `qqq.io/` and `goauthentik.io/` keys are kept and cannot be set; blank leaves them as they are; editing attributes needs `authentik.audit.read`, which is also what shows them on the form). **Edit Group** edits other attributes the same way.
|
||||
- **Delete User** and **Delete Group** record actions behind the new `DELETE` feature (off by default; needs Authentik's `delete_user` / `delete_group`). Typed confirmation; never yourself, a protected account, a superuser group or the last identity administrator; a group must have no members and no child groups first; the group's `authentik_policy` row goes with it. Deactivate stays the default for people.
|
||||
|
||||
### Added (policy editor, phase 5b: DESIGN.md section 12.4)
|
||||
- **Edit Policy** record action on groups and on policies (feature `POLICIES`, permission `authentik.policies.manage`): a standard streamed-ETL action whose step shows the `AUTHENTIK_POLICY_EDITOR` widget (Next UI). Review parses the submitted document (fail closed), regenerates it in canonical form with the round-trip check, refuses new resources this app does not define, applies the subset rule to the permissions it adds, and lists what is added and removed. Result merge-patches `qqq.io/policy` into the group's current attributes (removed when empty). Optional second-person approval (`policyTwoPersonApproval`).
|
||||
- **`PolicyCatalog`**: every app, table, process (hidden record actions included), report and protected widget a policy can grant, with the actions QQQ checks, grouped by app. Its permission names join the permission catalog.
|
||||
|
||||
@@ -51,7 +51,7 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
- `check_access?for_user=` is useless for the service account: it is silently evaluated for the caller. Never use it for other users.
|
||||
- Nothing but `AuthentikAdmin` may call `AuthentikActionUtil.write`. Every write needs a reason, passes the guard, and is audited first.
|
||||
- `recovery_email` needs the brand to have a recovery flow ("No recovery flow set." otherwise), the role's `add_user` (DRF maps POST to add) plus `reset_user_password`, and `view_emailstage` on the stage.
|
||||
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key, impersonate or delete permissions.
|
||||
- The service account must never be a superuser and never hold `enable_group_superuser`, token-key or impersonate permissions; `delete_user`/`delete_group` only when the host turns the `DELETE` feature on.
|
||||
|
||||
## Code Style
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. No hard delete. |
|
||||
| **Groups and permissions** | Create and edit groups (parents), add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
|
||||
| **People** | Create a user (Authentik emails them a link to set their own password; QQQ never sets or sees one), invite, edit, deactivate and reactivate, send a password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations. Edit covers username, name, email, path and other attributes. Delete is a separate, explicit action behind the `DELETE` feature (off by default); deactivate stays the default. |
|
||||
| **Groups and permissions** | Create, edit (parents, other attributes) and delete groups, add and remove members, and edit the QQQ permissions and app sign-in a group grants (`qqq.io/permissions`, `qqq.io/app-access` on the Authentik group). |
|
||||
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
|
||||
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
|
||||
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
|
||||
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, connection), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
|
||||
@@ -23,7 +24,7 @@ AuthentikQBitConfig config = new AuthentikQBitConfig()
|
||||
.withBaseUrl("https://auth.example.com") // host root; /api/v3 is appended
|
||||
.withApiToken("${env.AUTHENTIK_API_TOKEN}") // service-account token, resolved by the qbit
|
||||
.withAppKey("my-app-admin") // service account: qqq-my-app-admin-idadmin
|
||||
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS))
|
||||
.withFeatures(EnumSet.of(READ, USERS, GROUPS, MEMBERSHIP, CREDENTIALS, SESSIONS, POLICIES)) // + DELETE for delete actions
|
||||
.withLocalBackendName("rdbms") // audit trail and approval requests (see docs/schema.sql)
|
||||
.withRecoveryEmailStage("my-recovery-email") // Authentik email stage (name or uuid)
|
||||
.withInvitationFlow("my-enrollment"); // optional: enables invitations
|
||||
@@ -38,10 +39,10 @@ Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill s
|
||||
| | |
|
||||
|---|---|
|
||||
| API backend | `authentikApi` |
|
||||
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
|
||||
| Main tables (nav) | `authentik_user`, `authentik_invitation`, `authentik_group`, `authentik_policy` (with `POLICIES`), `authentik_application`, `authentik_approval_request`, `authentik_admin_action` |
|
||||
| Child lists (on their parents, not in the nav) | `authentik_group_member`, `authentik_session`, `authentik_token` (metadata only, never a key), `authentik_event`, `authentik_policy_binding`, `authentik_role`, `authentik_blueprint` |
|
||||
| App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report |
|
||||
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject |
|
||||
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group |
|
||||
|
||||
### Permissions
|
||||
|
||||
@@ -54,6 +55,7 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
|
||||
| `authentik.groups.manage` | create and edit groups |
|
||||
| `authentik.memberships.manage` | add and remove members (subset rule) |
|
||||
| `authentik.permissions.manage` | edit a group's permissions and app access (subset rule) |
|
||||
| `authentik.policies.manage` | edit a group's policy (subset rule) |
|
||||
| `authentik.credentials.reset` | password reset email, reset MFA, revoke app passwords |
|
||||
| `authentik.sessions.revoke` | revoke sessions |
|
||||
| `authentik.audit.read` | sessions, tokens, events, the audit trail, sensitive fields |
|
||||
@@ -65,9 +67,9 @@ QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute
|
||||
|
||||
### Authentik side
|
||||
|
||||
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, token-key, impersonate or delete permissions. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
|
||||
The qbit's API token belongs to a dedicated Authentik service account whose role holds only the permissions it needs (DESIGN.md section 5.1), created by a bootstrap blueprint in the host's GitOps repository. It is never a superuser and never holds `enable_group_superuser`, token-key or impersonate permissions, and holds delete permissions only when the `DELETE` feature is on. [`src/test/resources/blueprints/kof22-identity-bootstrap.yaml`](src/test/resources/blueprints/kof22-identity-bootstrap.yaml) is one host's worked example.
|
||||
|
||||
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. The contract tests grant exactly this list.
|
||||
Write features need these in the role, on top of the read set: `add_user`, `change_user`, `reset_user_password`, `add_group`, `change_group`, `add_user_to_group`, `remove_user_from_group`, `view_token`, `delete_token`, `delete_authenticatedsession`, invitation `view`/`add`/`delete` (if you use invitations), `authentik_flows.view_flow` (to find the invitation flow by slug), and `view_emailstage` on the recovery stage. Saving a policy uses `change_group`. The `DELETE` feature also needs `delete_user` and `delete_group`. The contract tests grant exactly this list.
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
|
||||
+3
-3
@@ -11,14 +11,14 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated
|
||||
| 2. Memberships and permission editing | done, except the webhook route, ESB trigger, inbox and session re-evaluation marking |
|
||||
| 3. Full user lifecycle | done, except the optional snapshot export |
|
||||
| 4. RBAC role management | post-v1, not started |
|
||||
| 5. Policies and full CRUD (DESIGN.md section 12) | spec approved 2026-10-13; 5a policy model, 5b editor, 5c CRUD additions in progress |
|
||||
| 5. Policies and full CRUD (DESIGN.md section 12) | 5a policy model (#14), 5b policy editor (#15), 5c user and group CRUD additions; the Next UI widget is a separate qqq-frontend-next PR |
|
||||
|
||||
### Built in this line (0.1)
|
||||
|
||||
| Area | What |
|
||||
|---|---|
|
||||
| People | Create, invite, edit, deactivate, reactivate, password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
|
||||
| Groups | Create, edit (parents), add and remove members (bulk), edit permissions and app access |
|
||||
| People | Create, invite, edit (username, name, email, path, other attributes), deactivate, reactivate, delete (feature `DELETE`), password reset email, reset MFA, revoke app passwords, revoke sessions, cancel invitations |
|
||||
| Groups | Create, edit (parents, other attributes), delete (feature `DELETE`), add and remove members (bulk), edit permissions and app access, edit the group policy (feature `POLICIES`) |
|
||||
| Safety | Subset rule on live groups, protected accounts and superuser groups, no self-edits, last-admin protection, reasons, typed confirmations, bulk caps, stale checks, two-person approval for `authentik.*` |
|
||||
| Audit | `authentik_admin_action` (before the call), Authentik `custom_qqq_admin_action` events, approval requests |
|
||||
| Screens | Home dashboard, main tables in the nav, child lists on parents, every action as a table or record action |
|
||||
|
||||
@@ -46,11 +46,11 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
{
|
||||
/** Features implemented in this release. Others are refused by validate(). */
|
||||
public static final Set<AuthentikFeature> AVAILABLE_FEATURES = EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES);
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE);
|
||||
|
||||
/** Features that change Authentik data; they need a local backend for the audit trail. */
|
||||
public static final Set<AuthentikFeature> WRITE_FEATURES = EnumSet.of(AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES);
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE);
|
||||
|
||||
private static final Pattern APP_KEY_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,48}[a-z0-9]");
|
||||
private static final Pattern PREFIX_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9]{0,30}");
|
||||
@@ -179,6 +179,11 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
errors.add("features USERS and CREDENTIALS need recoveryEmailStage");
|
||||
}
|
||||
|
||||
if(requested.contains(AuthentikFeature.DELETE) && !requested.contains(AuthentikFeature.USERS) && !requested.contains(AuthentikFeature.GROUPS))
|
||||
{
|
||||
errors.add("feature DELETE needs USERS or GROUPS");
|
||||
}
|
||||
|
||||
if(requested.contains(AuthentikFeature.POLICIES) && !requested.contains(AuthentikFeature.GROUPS))
|
||||
{
|
||||
errors.add("feature POLICIES needs GROUPS");
|
||||
|
||||
@@ -39,6 +39,8 @@ public enum AdminAction
|
||||
EDIT_GROUP("Edit group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
|
||||
EDIT_PERMISSIONS("Edit permissions and app access", "group", IdentityPermission.PERMISSIONS_MANAGE, AuthentikFeature.GROUPS, Confirmation.CONFIRM),
|
||||
SAVE_POLICY("Save policy", "group", IdentityPermission.POLICIES_MANAGE, AuthentikFeature.POLICIES, Confirmation.CONFIRM),
|
||||
DELETE_USER("Delete user", "user", IdentityPermission.USERS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
|
||||
DELETE_GROUP("Delete group", "group", IdentityPermission.GROUPS_MANAGE, AuthentikFeature.DELETE, Confirmation.TYPED),
|
||||
SEND_RECOVERY_EMAIL("Send password reset email", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.CONFIRM),
|
||||
RESET_MFA("Reset MFA", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
|
||||
REVOKE_APP_PASSWORDS("Revoke app passwords", "user", IdentityPermission.CREDENTIALS_RESET, AuthentikFeature.CREDENTIALS, Confirmation.TYPED),
|
||||
|
||||
@@ -93,6 +93,9 @@ public class AuthentikAdmin
|
||||
/** Process value / request parameter that carries a submitted policy document. */
|
||||
public static final String POLICY_DOCUMENT = "policyDocument";
|
||||
|
||||
/** Request parameter with the non-reserved attributes (JSON object text) of Edit User and Edit Group. */
|
||||
public static final String OTHER_ATTRIBUTES = "otherAttributes";
|
||||
|
||||
private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
|
||||
private static final Pattern USERNAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._@+-]{0,149}");
|
||||
private static final Pattern EMAIL = Pattern.compile("[^@\\s]+@[^@\\s]+\\.[^@\\s]+");
|
||||
@@ -261,6 +264,8 @@ public class AuthentikAdmin
|
||||
case EDIT_GROUP -> planEditGroup(plan, request);
|
||||
case EDIT_PERMISSIONS -> planEditPermissions(plan, request);
|
||||
case SAVE_POLICY -> planSavePolicy(plan, request);
|
||||
case DELETE_USER -> planDeleteUser(plan, request);
|
||||
case DELETE_GROUP -> planDeleteGroup(plan, request);
|
||||
case SEND_RECOVERY_EMAIL -> planRecovery(plan, request);
|
||||
case RESET_MFA -> planResetMfa(plan, request);
|
||||
case REVOKE_APP_PASSWORDS -> planAppPasswords(plan, request);
|
||||
@@ -665,6 +670,23 @@ public class AuthentikAdmin
|
||||
{
|
||||
return (plan);
|
||||
}
|
||||
String username = request.params().containsKey("username") && StringUtils.hasContent(request.str("username")) ? request.str("username") : user.optString("username");
|
||||
if(!username.equals(user.optString("username")))
|
||||
{
|
||||
if(!USERNAME.matcher(username).matches())
|
||||
{
|
||||
return (plan.refuse("A username is letters, digits and . _ @ + - (up to 150)."));
|
||||
}
|
||||
if(findUserByUsername(username) != null)
|
||||
{
|
||||
return (plan.refuse("Another user already has the username " + username + "."));
|
||||
}
|
||||
}
|
||||
String attributesProblem = otherAttributesProblem(request, user.optJSONObject("attributes"));
|
||||
if(attributesProblem != null)
|
||||
{
|
||||
return (plan.refuse(attributesProblem));
|
||||
}
|
||||
String name = request.params().containsKey("name") ? request.str("name") : user.optString("name");
|
||||
String email = request.params().containsKey("email") ? request.str("email") : user.optString("email");
|
||||
String path = request.params().containsKey("path") ? request.str("path") : user.optString("path");
|
||||
@@ -694,8 +716,14 @@ public class AuthentikAdmin
|
||||
return (plan.refuse("This user was changed (for example in the Authentik admin UI) since you opened it. Review it again."));
|
||||
}
|
||||
|
||||
String before = user.optString("name") + " <" + user.optString("email") + ">, " + user.optString("path");
|
||||
String after = name + " <" + email + ">, " + path;
|
||||
String before = user.optString("username") + ": " + user.optString("name") + " <" + user.optString("email") + ">, " + user.optString("path");
|
||||
String after = username + ": " + name + " <" + email + ">, " + path;
|
||||
String keys = changedAttributeKeys(request, user.optJSONObject("attributes"));
|
||||
if(keys != null)
|
||||
{
|
||||
before = before + "; attributes";
|
||||
after = after + "; attributes (" + keys + ")";
|
||||
}
|
||||
if(before.equals(after))
|
||||
{
|
||||
return (plan.noChange("Nothing to change."));
|
||||
@@ -932,7 +960,13 @@ public class AuthentikAdmin
|
||||
{
|
||||
return (plan.refuse("This group was changed (for example in the Authentik admin UI) since you opened it. Review it again."));
|
||||
}
|
||||
if(newName.equals(group.optString("name")) && new LinkedHashSet<>(newParents).equals(new LinkedHashSet<>(currentParents)))
|
||||
String attributesProblem = otherAttributesProblem(request, group.optJSONObject("attributes"));
|
||||
if(attributesProblem != null)
|
||||
{
|
||||
return (plan.refuse(attributesProblem));
|
||||
}
|
||||
String keys = changedAttributeKeys(request, group.optJSONObject("attributes"));
|
||||
if(newName.equals(group.optString("name")) && new LinkedHashSet<>(newParents).equals(new LinkedHashSet<>(currentParents)) && keys == null)
|
||||
{
|
||||
return (plan.noChange("Nothing to change."));
|
||||
}
|
||||
@@ -949,7 +983,8 @@ public class AuthentikAdmin
|
||||
}
|
||||
|
||||
plan.getRequest().params().put("resolvedParentPks", String.join(",", newParents));
|
||||
return (plan.withChange(group.optString("name") + ", parents " + names(currentParents), newName + ", parents " + names(newParents)));
|
||||
return (plan.withChange(group.optString("name") + ", parents " + names(currentParents) + (keys == null ? "" : "; attributes"),
|
||||
newName + ", parents " + names(newParents) + (keys == null ? "" : "; attributes (" + keys + ")")));
|
||||
}
|
||||
|
||||
|
||||
@@ -1113,6 +1148,235 @@ public class AuthentikAdmin
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a person (DESIGN.md section 12.5): explicit, typed, never yourself,
|
||||
** a protected account or the last identity administrator.
|
||||
*******************************************************************************/
|
||||
private AdminPlan planDeleteUser(AdminPlan plan, AdminRequest request) throws QException
|
||||
{
|
||||
JSONObject user = targetUser(plan, request.targetKey(), false);
|
||||
if(user == null)
|
||||
{
|
||||
return (plan);
|
||||
}
|
||||
String typed = typedProblem(request, user.optString("username"));
|
||||
if(typed != null)
|
||||
{
|
||||
return (plan.refuse(typed));
|
||||
}
|
||||
Set<Integer> before = adminsNow();
|
||||
if(before.contains(user.optInt("pk")))
|
||||
{
|
||||
List<AuthentikSafety.UserFacts> changed = new ArrayList<>();
|
||||
for(AuthentikSafety.UserFacts facts : userFacts())
|
||||
{
|
||||
if(facts.pk() != user.optInt("pk"))
|
||||
{
|
||||
changed.add(facts);
|
||||
}
|
||||
}
|
||||
if(AuthentikSafety.removesLastAdmin(before, AuthentikSafety.qualifyingAdmins(membersGraph(), new EffectivePermissionResolver(membersGraph(), runtime.getPermissionCatalog()), changed)))
|
||||
{
|
||||
return (plan.refuse("This is the last active person who can manage memberships and permissions. Add another administrator first."));
|
||||
}
|
||||
}
|
||||
plan.getNotes().add("Deleting cannot be undone. Deactivate User keeps the account and its history.");
|
||||
return (plan.withChange(user.optString("username") + " <" + user.optString("email") + ">", "deleted"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a group (DESIGN.md section 12.5): explicit, typed, and only when it
|
||||
** has no members and no child groups (remove them first, so nobody loses
|
||||
** access by surprise).
|
||||
*******************************************************************************/
|
||||
private AdminPlan planDeleteGroup(AdminPlan plan, AdminRequest request) throws QException
|
||||
{
|
||||
JSONObject group = targetGroup(plan, request.targetKey());
|
||||
if(group == null)
|
||||
{
|
||||
return (plan);
|
||||
}
|
||||
String name = group.optString("name");
|
||||
if(!request.str("confirmText").equals(name) && !request.bool("approved"))
|
||||
{
|
||||
return (plan.refuse("Type the group name (" + name + ") to confirm."));
|
||||
}
|
||||
JSONArray users = group.optJSONArray("users");
|
||||
if(users != null && !users.isEmpty())
|
||||
{
|
||||
return (plan.refuse("The group has " + users.length() + " member(s). Remove them first."));
|
||||
}
|
||||
List<String> children = new ArrayList<>();
|
||||
for(GroupGraph.Node node : graph().nodes())
|
||||
{
|
||||
if(node.parentPks().contains(request.targetKey()))
|
||||
{
|
||||
children.add(node.name());
|
||||
}
|
||||
}
|
||||
if(!children.isEmpty())
|
||||
{
|
||||
return (plan.refuse("The group has child groups (" + String.join(", ", children) + "). Change their parents first."));
|
||||
}
|
||||
plan.getNotes().add("Deleting cannot be undone. Its permissions, app access and policy go with it.");
|
||||
return (plan.withChange(name, "deleted"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Why the submitted other-attributes JSON cannot be applied, or null. It must
|
||||
** be a JSON object without reserved keys (qqq.io/ and goauthentik.io/ are
|
||||
** set elsewhere), and editing attributes needs authentik.audit.read
|
||||
** (attributes are sensitive, DESIGN.md section 5.3).
|
||||
*******************************************************************************/
|
||||
private String otherAttributesProblem(AdminRequest request, JSONObject current) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(request.str(OTHER_ATTRIBUTES)))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
JSONObject submitted = parseOtherAttributes(request.str(OTHER_ATTRIBUTES));
|
||||
for(String key : submitted.keySet())
|
||||
{
|
||||
if(isReservedAttribute(key))
|
||||
{
|
||||
return ("The attribute " + key + " is reserved; it is set by Edit Permissions, Edit Policy or Authentik itself.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
return (e.getMessage());
|
||||
}
|
||||
if(changedAttributeKeys(request, current) != null && !operator().permissions().contains(IdentityPermission.AUDIT_READ.getSessionPermission()) && !operator().bootstrap())
|
||||
{
|
||||
return ("Editing attributes needs " + IdentityPermission.AUDIT_READ.getShortName() + ".");
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The keys whose values the submitted attributes change (added, changed or
|
||||
** removed), or null for no change. Blank means "leave them as they are"
|
||||
** ({} removes them all). A null current compares against nothing.
|
||||
*******************************************************************************/
|
||||
private static String changedAttributeKeys(AdminRequest request, JSONObject current) throws QUserFacingException
|
||||
{
|
||||
if(!StringUtils.hasContent(request.str(OTHER_ATTRIBUTES)))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
JSONObject submitted = parseOtherAttributes(request.str(OTHER_ATTRIBUTES));
|
||||
JSONObject others = otherAttributes(current);
|
||||
Set<String> changed = new TreeSet<>();
|
||||
for(String key : submitted.keySet())
|
||||
{
|
||||
if(!others.has(key) || !String.valueOf(others.get(key)).equals(String.valueOf(submitted.get(key))))
|
||||
{
|
||||
changed.add(key);
|
||||
}
|
||||
}
|
||||
for(String key : others.keySet())
|
||||
{
|
||||
if(!submitted.has(key))
|
||||
{
|
||||
changed.add(key);
|
||||
}
|
||||
}
|
||||
return (changed.isEmpty() ? null : String.join(", ", changed));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The current attributes with the non-reserved part replaced by the
|
||||
** submitted object (reserved keys are kept as they are).
|
||||
*******************************************************************************/
|
||||
static JSONObject mergeOtherAttributes(JSONObject current, String submittedText) throws QUserFacingException
|
||||
{
|
||||
JSONObject rs = new JSONObject();
|
||||
if(current != null)
|
||||
{
|
||||
for(String key : current.keySet())
|
||||
{
|
||||
if(isReservedAttribute(key))
|
||||
{
|
||||
rs.put(key, current.get(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
JSONObject submitted = parseOtherAttributes(submittedText);
|
||||
for(String key : submitted.keySet())
|
||||
{
|
||||
if(!isReservedAttribute(key))
|
||||
{
|
||||
rs.put(key, submitted.get(key));
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The non-reserved attributes, as shown on Edit User and Edit Group.
|
||||
*******************************************************************************/
|
||||
public static JSONObject otherAttributes(JSONObject attributes)
|
||||
{
|
||||
JSONObject rs = new JSONObject();
|
||||
if(attributes != null)
|
||||
{
|
||||
for(String key : attributes.keySet())
|
||||
{
|
||||
if(!isReservedAttribute(key))
|
||||
{
|
||||
rs.put(key, attributes.get(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keys QQQ manages elsewhere (qqq.io/) or Authentik owns (goauthentik.io/).
|
||||
*******************************************************************************/
|
||||
static boolean isReservedAttribute(String key)
|
||||
{
|
||||
return (key.startsWith(AuthentikJson.QQQ_ATTRIBUTE_PREFIX) || key.startsWith("goauthentik.io/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A submitted attributes text as a JSON object (blank means none).
|
||||
*******************************************************************************/
|
||||
private static JSONObject parseOtherAttributes(String text) throws QUserFacingException
|
||||
{
|
||||
if(!StringUtils.hasContent(text))
|
||||
{
|
||||
return (new JSONObject());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new JSONObject(text));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QUserFacingException("Other attributes must be a JSON object, e.g. {\"department\": \"sales\"}."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "n permission(s): a, b, ..." for the audit before/after.
|
||||
*******************************************************************************/
|
||||
@@ -1489,13 +1753,18 @@ public class AuthentikAdmin
|
||||
case EDIT_USER:
|
||||
{
|
||||
JSONObject body = new JSONObject();
|
||||
for(String key : List.of("name", "email", "path"))
|
||||
for(String key : List.of("username", "name", "email", "path"))
|
||||
{
|
||||
if(request.params().containsKey(key))
|
||||
if(request.params().containsKey(key) && (!key.equals("username") || StringUtils.hasContent(request.str(key))))
|
||||
{
|
||||
body.put(key, request.str(key));
|
||||
}
|
||||
}
|
||||
if(changedAttributeKeys(request, null) != null)
|
||||
{
|
||||
JSONObject fresh = util.getObject(AuthentikTables.get(AuthentikTables.USER), request.targetKey());
|
||||
body.put("attributes", mergeOtherAttributes(fresh == null ? null : fresh.optJSONObject("attributes"), request.str(OTHER_ATTRIBUTES)));
|
||||
}
|
||||
util.write(auth, "PATCH", "/core/users/" + safeKey(request.targetKey()) + "/", body);
|
||||
return ("User updated.");
|
||||
}
|
||||
@@ -1545,6 +1814,12 @@ public class AuthentikAdmin
|
||||
{
|
||||
body.put("name", request.str("name"));
|
||||
}
|
||||
if(changedAttributeKeys(request, null) != null)
|
||||
{
|
||||
runtime.invalidateCache();
|
||||
JSONObject fresh = util.getApiJsonOrNull("/core/groups/" + safeKey(request.targetKey()) + "/?include_users=false&include_parents=false&include_children=false");
|
||||
body.put("attributes", mergeOtherAttributes(fresh == null ? null : fresh.optJSONObject("attributes"), request.str(OTHER_ATTRIBUTES)));
|
||||
}
|
||||
if(request.params().containsKey("resolvedParentPks"))
|
||||
{
|
||||
JSONArray parents = new JSONArray();
|
||||
@@ -1578,6 +1853,22 @@ public class AuthentikAdmin
|
||||
}
|
||||
case SAVE_POLICY:
|
||||
return (performSavePolicy(plan, auth));
|
||||
case DELETE_USER:
|
||||
util.write(auth, "DELETE", "/core/users/" + safeKey(request.targetKey()) + "/", null);
|
||||
return ("User deleted in Authentik.");
|
||||
case DELETE_GROUP:
|
||||
{
|
||||
util.write(auth, "DELETE", "/core/groups/" + safeKey(request.targetKey()) + "/", null);
|
||||
try
|
||||
{
|
||||
PolicyStore.delete(config, request.targetKey());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("The group was deleted but its policy record could not be removed", logPair("group", request.targetKey()), logPair("error", e.getMessage()));
|
||||
}
|
||||
return ("Group deleted in Authentik.");
|
||||
}
|
||||
case SEND_RECOVERY_EMAIL:
|
||||
sendRecovery(auth, request.targetKey());
|
||||
return ("Password reset email sent by Authentik.");
|
||||
|
||||
@@ -25,11 +25,13 @@ import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
@@ -111,6 +113,23 @@ public final class PolicyStore
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove the row of a deleted group (no-op without one).
|
||||
*******************************************************************************/
|
||||
public static void delete(AuthentikQBitConfig config, String groupPk) throws QException
|
||||
{
|
||||
QRecord existing = find(config, groupPk);
|
||||
if(existing != null)
|
||||
{
|
||||
DeleteInput deleteInput = new DeleteInput();
|
||||
deleteInput.setTableName(config.applyTablePrefix(AuthentikLocalTables.POLICY));
|
||||
deleteInput.setPrimaryKeys(List.of(existing.getValue("id")));
|
||||
new DeleteAction().execute(deleteInput);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert or update the row of a group after a save to Authentik.
|
||||
**
|
||||
|
||||
@@ -150,14 +150,20 @@ public final class AuthentikProcesses
|
||||
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email").withIsRequired(true),
|
||||
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"),
|
||||
reason), "Authentik emails a single-use sign-up invitation that expires in 7 days."));
|
||||
QFieldMetaData otherAttributes = new QFieldMetaData(AuthentikAdmin.OTHER_ATTRIBUTES, QFieldType.TEXT).withLabel("Other attributes (JSON; leave blank to keep them)");
|
||||
defs.add(new Def("editUser", "Edit User", AdminAction.EDIT_USER, Kind.USERS, users, "edit", true, List.of(
|
||||
new QFieldMetaData("username", QFieldType.STRING).withLabel("Username"),
|
||||
new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"),
|
||||
new QFieldMetaData("email", QFieldType.STRING).withLabel("Email"),
|
||||
new QFieldMetaData("path", QFieldType.STRING).withLabel("Path"),
|
||||
hidden("expectedFingerprint"), reason), "Change a person's name, email or path."));
|
||||
otherAttributes,
|
||||
hidden("expectedFingerprint"), reason), "Change a person's username, name, email, path or other attributes. "
|
||||
+ "Attributes starting with qqq.io/ or goauthentik.io/ are set elsewhere; editing attributes needs authentik.audit.read."));
|
||||
defs.add(new Def("deactivateUser", "Deactivate User", AdminAction.DEACTIVATE_USER, Kind.USERS, users, "block", false, List.of(
|
||||
new QFieldMetaData("revokeAppPasswords", QFieldType.BOOLEAN).withLabel("Also revoke their app passwords").withDefaultValue(false),
|
||||
typed, reason), "Deactivating ends their sessions immediately and deletes nothing. Type the username to confirm (when you select several people, type the username of the first one)."));
|
||||
defs.add(new Def("deleteUser", "Delete User", AdminAction.DELETE_USER, Kind.USERS, users, "delete_forever", false, List.of(typed, reason),
|
||||
"Deletes the account in Authentik permanently. To keep the account and its history, use Deactivate User instead."));
|
||||
defs.add(new Def("reactivateUser", "Reactivate User", AdminAction.REACTIVATE_USER, Kind.USERS, users, "check_circle", false, List.of(reason), null));
|
||||
defs.add(new Def("addToGroup", "Add To Group", AdminAction.ADD_MEMBER, Kind.USERS, users, "group_add", false, List.of(group,
|
||||
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Confirm the number of people (needed only above 25)").withIsHidden(false), reason),
|
||||
@@ -181,7 +187,11 @@ public final class AuthentikProcesses
|
||||
defs.add(new Def("editGroup", "Edit Group", AdminAction.EDIT_GROUP, Kind.GROUPS, groups, "edit", true, List.of(
|
||||
new QFieldMetaData("name", QFieldType.STRING).withLabel("Group Name"),
|
||||
new QFieldMetaData("parents", QFieldType.TEXT).withLabel("Parent groups (names, one per line; none to clear)"),
|
||||
hidden("expectedFingerprint"), reason), "Rename a group or change its parents."));
|
||||
otherAttributes,
|
||||
hidden("expectedFingerprint"), reason), "Rename a group, change its parents or its other attributes."));
|
||||
defs.add(new Def("deleteGroup", "Delete Group", AdminAction.DELETE_GROUP, Kind.GROUPS, groups, "delete_forever", false, List.of(
|
||||
new QFieldMetaData("confirmText", QFieldType.STRING).withLabel("Type the group name to confirm").withIsRequired(true), reason),
|
||||
"Deletes the group in Authentik permanently, with its permissions, app access and policy. Remove its members and child groups first."));
|
||||
defs.add(new Def("editPermissions", "Edit Permissions And App Access", AdminAction.EDIT_PERMISSIONS, Kind.GROUPS, groups, "admin_panel_settings", true, List.of(
|
||||
new QFieldMetaData("permissions", QFieldType.TEXT).withLabel("QQQ permissions (one per line)"),
|
||||
new QFieldMetaData("appAccess", QFieldType.TEXT).withLabel("App access (app keys, one per line)"),
|
||||
@@ -204,6 +214,8 @@ public final class AuthentikProcesses
|
||||
defs.add(new Def("reject", "Reject", null, Kind.APPROVALS, AuthentikLocalTables.APPROVAL_REQUEST, "close", false, List.of(), null));
|
||||
|
||||
defs.removeIf(def -> def.action() != null && !config.hasFeature(def.action().getFeature()));
|
||||
defs.removeIf(def -> (def.action() == AdminAction.DELETE_USER && !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.USERS))
|
||||
|| (def.action() == AdminAction.DELETE_GROUP && !config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS)));
|
||||
if(!config.hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.GROUPS) || !config.getTwoPersonApproval())
|
||||
{
|
||||
defs.removeIf(def -> def.kind() == Kind.APPROVALS);
|
||||
@@ -284,7 +296,7 @@ public final class AuthentikProcesses
|
||||
{
|
||||
process.setMinInputRecords(1);
|
||||
process.setMaxInputRecords(def.action() == AdminAction.EDIT_USER || def.action() == AdminAction.EDIT_GROUP || def.action() == AdminAction.EDIT_PERMISSIONS
|
||||
|| def.action() == AdminAction.SAVE_POLICY ? 1 : AuthentikAdmin.MAX_BULK);
|
||||
|| def.action() == AdminAction.SAVE_POLICY || def.action() == AdminAction.DELETE_USER || def.action() == AdminAction.DELETE_GROUP ? 1 : AuthentikAdmin.MAX_BULK);
|
||||
}
|
||||
default ->
|
||||
{
|
||||
@@ -352,6 +364,8 @@ public final class AuthentikProcesses
|
||||
case CREATE_GROUP -> w("group will be created", "groups will be created", "group was created", "groups were created");
|
||||
case EDIT_GROUP -> w("group will be updated", "groups will be updated", "group was updated", "groups were updated");
|
||||
case EDIT_PERMISSIONS -> w("group's permissions will be updated", "groups' permissions will be updated", "group's permissions were updated", "groups' permissions were updated");
|
||||
case DELETE_USER -> w("user will be deleted", "users will be deleted", "user was deleted", "users were deleted");
|
||||
case DELETE_GROUP -> w("group will be deleted", "groups will be deleted", "group was deleted", "groups were deleted");
|
||||
case SAVE_POLICY -> w("group's policy will be saved to Authentik", "groups' policies will be saved to Authentik", "group's policy was saved to Authentik", "groups' policies were saved to Authentik");
|
||||
case SEND_RECOVERY_EMAIL -> w("password reset email will be sent", "password reset emails will be sent", "password reset email was sent", "password reset emails were sent");
|
||||
case RESET_MFA -> w("user's MFA will be reset", "users' MFA will be reset", "user's MFA was reset", "users' MFA were reset");
|
||||
@@ -494,6 +508,18 @@ public final class AuthentikProcesses
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Attributes are sensitive (DESIGN.md section 5.3): they are shown on the
|
||||
** edit forms only with authentik.audit.read; left blank, they are unchanged.
|
||||
*******************************************************************************/
|
||||
static boolean canSeeAttributes()
|
||||
{
|
||||
return (QContext.getQSession() != null && QContext.getQSession().getPermissions() != null
|
||||
&& QContext.getQSession().getPermissions().contains(IdentityPermission.AUDIT_READ.getSessionPermission()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The details screen is filled from the selected record (edit actions).
|
||||
*******************************************************************************/
|
||||
@@ -529,6 +555,11 @@ public final class AuthentikProcesses
|
||||
JSONObject user = admin.getUtil().getObject(AuthentikTables.get(AuthentikTables.USER), pk);
|
||||
if(user != null)
|
||||
{
|
||||
output.addValue("username", user.optString("username"));
|
||||
if(canSeeAttributes())
|
||||
{
|
||||
output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(user.optJSONObject("attributes")).toString(2));
|
||||
}
|
||||
output.addValue("name", user.optString("name"));
|
||||
output.addValue("email", user.optString("email"));
|
||||
output.addValue("path", user.optString("path"));
|
||||
@@ -543,6 +574,10 @@ public final class AuthentikProcesses
|
||||
output.addValue("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
|
||||
if(def.action() == AdminAction.EDIT_GROUP)
|
||||
{
|
||||
if(canSeeAttributes())
|
||||
{
|
||||
output.addValue(AuthentikAdmin.OTHER_ATTRIBUTES, AuthentikAdmin.otherAttributes(group.optJSONObject("attributes")).toString(2));
|
||||
}
|
||||
output.addValue("name", group.optString("name"));
|
||||
JSONArray parents = group.optJSONArray("parents_obj");
|
||||
List<String> names = new ArrayList<>();
|
||||
|
||||
@@ -297,6 +297,29 @@ public class FakeAuthentik
|
||||
user.put("last_updated", "2026-10-12T01:00:00Z");
|
||||
return (ok(200, render(user)));
|
||||
}
|
||||
if(path.matches("/core/users/[0-9]+/") && method.equals("DELETE"))
|
||||
{
|
||||
int pk = Integer.parseInt(path.split("/")[3]);
|
||||
users.remove(pk);
|
||||
for(JSONObject group : groups.values())
|
||||
{
|
||||
JSONArray kept = new JSONArray();
|
||||
for(int i = 0; i < group.getJSONArray("users").length(); i++)
|
||||
{
|
||||
if(group.getJSONArray("users").getInt(i) != pk)
|
||||
{
|
||||
kept.put(group.getJSONArray("users").getInt(i));
|
||||
}
|
||||
}
|
||||
group.put("users", kept);
|
||||
}
|
||||
return (ok(204, null));
|
||||
}
|
||||
if(path.matches("/core/groups/[^/]+/") && method.equals("DELETE"))
|
||||
{
|
||||
groups.remove(path.split("/")[3]);
|
||||
return (ok(204, null));
|
||||
}
|
||||
if(path.matches("/core/groups/[^/]+/") && method.equals("PATCH"))
|
||||
{
|
||||
JSONObject group = groups.get(path.split("/")[3]);
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.admin;
|
||||
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyStore;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full user and group CRUD (DESIGN.md section 12.5): username and attribute
|
||||
** edits, delete user and delete group, and their gating.
|
||||
*******************************************************************************/
|
||||
class UserGroupCrudTest
|
||||
{
|
||||
private AdminTestSupport t;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
|
||||
AuthentikFeature.POLICIES, AuthentikFeature.DELETE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AuthentikAdmin.AdminResult run(AdminRequest request) throws QException
|
||||
{
|
||||
return (t.admin().execute(request));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Username changes are validated and unique.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void editUsername() throws QException
|
||||
{
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "rename").with("username", "second")).message()).contains("already has the username");
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "rename").with("username", "has space")).message()).contains("A username is");
|
||||
|
||||
AuthentikAdmin.AdminResult renamed = run(AdminRequest.of(AdminAction.EDIT_USER, "3", "rename").with("username", "samuel"));
|
||||
assertThat(renamed.ok()).as(renamed.message()).isTrue();
|
||||
assertThat(t.authentik.users.get(3).getString("username")).isEqualTo("samuel");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Other attributes replace the non-reserved part; reserved keys survive and
|
||||
** cannot be set; blank leaves them as they are; editing needs audit.read.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void editUserAttributes() throws QException
|
||||
{
|
||||
t.authentik.users.get(3).put("attributes", new JSONObject().put("qqq.io/protected", false).put("goauthentik.io/user/token-expires", true).put("team", "web").put("old", 1));
|
||||
|
||||
AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "{\"team\": \"ops\", \"desk\": 4}"));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
JSONObject attributes = t.authentik.users.get(3).getJSONObject("attributes");
|
||||
assertThat(attributes.getString("team")).isEqualTo("ops");
|
||||
assertThat(attributes.getInt("desk")).isEqualTo(4);
|
||||
assertThat(attributes.has("old")).isFalse();
|
||||
assertThat(attributes.getBoolean("qqq.io/protected")).isFalse();
|
||||
assertThat(attributes.getBoolean("goauthentik.io/user/token-expires")).isTrue();
|
||||
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "{\"qqq.io/protected\": true}")).message()).contains("reserved");
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "[1]")).message()).contains("JSON object");
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "").with("name", "Sam B")).ok()).isTrue();
|
||||
assertThat(t.authentik.users.get(3).getJSONObject("attributes").getString("team")).as("blank keeps them").isEqualTo("ops");
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
// an operator without authentik.audit.read: refused //
|
||||
///////////////////////////////////////////////////////
|
||||
t.authentik.groupByName("identity-admins").getJSONObject("attributes").put("qqq.io/permissions", new JSONArray(List.of(
|
||||
"authentik.users.read", "authentik.users.manage", "authentik.memberships.manage", "authentik.permissions.manage", "authentik.groups.manage")));
|
||||
t.admin().getUtil().getRuntime().invalidateCache();
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_USER, "3", "update").with("otherAttributes", "{\"team\": \"x\"}")).message()).contains("authentik.audit.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group attributes the same way.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void editGroupAttributes() throws QException
|
||||
{
|
||||
String pk = t.authentik.pk("kof-website-analysts");
|
||||
AuthentikAdmin.AdminResult result = run(AdminRequest.of(AdminAction.EDIT_GROUP, pk, "update").with("otherAttributes", "{\"owner\": \"data team\"}"));
|
||||
assertThat(result.ok()).as(result.message()).isTrue();
|
||||
JSONObject attributes = t.authentik.groupByName("kof-website-analysts").getJSONObject("attributes");
|
||||
assertThat(attributes.getString("owner")).isEqualTo("data team");
|
||||
assertThat(attributes.getJSONArray("qqq.io/permissions").toList()).containsExactly("kof22.analytics.hasAccess");
|
||||
assertThat(run(AdminRequest.of(AdminAction.EDIT_GROUP, pk, "update").with("otherAttributes", "{\"owner\": \"data team\"}")).noChange()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete user: typed confirmation, never protected accounts or yourself.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deleteUser() throws QException
|
||||
{
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "3", "left")).message()).contains("Type the username (sam)");
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "4", "left").with("confirmText", "breakglass")).message()).contains("Protected");
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "1", "left").with("confirmText", "operator")).message()).contains("your own account");
|
||||
assertThat(t.changes()).isEmpty();
|
||||
|
||||
AuthentikAdmin.AdminResult deleted = run(AdminRequest.of(AdminAction.DELETE_USER, "3", "left the company").with("confirmText", "sam"));
|
||||
assertThat(deleted.ok()).as(deleted.message()).isTrue();
|
||||
assertThat(t.authentik.users).doesNotContainKey(3);
|
||||
assertThat(t.changes()).containsExactly("DELETE /core/users/3/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete group: typed, and only empty groups without children; the policy
|
||||
** record goes with it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deleteGroup() throws Exception
|
||||
{
|
||||
String publishers = t.authentik.pk("kof-website-publishers");
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, publishers, "cleanup").with("confirmText", "kof-website-publishers")).message()).contains("member(s)");
|
||||
|
||||
t.authentik.group("child-of-analysts", false, List.of(), List.of(), "kof-website-analysts");
|
||||
t.admin().getUtil().getRuntime().invalidateCache();
|
||||
String analysts = t.authentik.pk("kof-website-analysts");
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, analysts, "cleanup")).message()).contains("Type the group name");
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, analysts, "cleanup").with("confirmText", "kof-website-analysts")).message()).contains("child groups (child-of-analysts)");
|
||||
|
||||
String child = t.authentik.pk("child-of-analysts");
|
||||
PolicyStore.save(t.config, child, "child-of-analysts", new JSONObject().put("PolicySpec", "qqq-iam").put("PolicyVersion", "1.0.0").put("Statement", new JSONArray()), 0, "x", null);
|
||||
AuthentikAdmin.AdminResult deleted = run(AdminRequest.of(AdminAction.DELETE_GROUP, child, "cleanup").with("confirmText", "child-of-analysts"));
|
||||
assertThat(deleted.ok()).as(deleted.message()).isTrue();
|
||||
assertThat(t.authentik.groups).doesNotContainKey(child);
|
||||
assertThat(PolicyStore.find(t.config, child)).isNull();
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_GROUP, t.authentik.pk("authentik Admins"), "x").with("confirmText", "authentik Admins")).message()).contains("Superuser groups");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DELETE is off by default: no processes, and the action is refused; the
|
||||
** permission is still required with it on.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void gating() throws QException
|
||||
{
|
||||
assertThat(t.qInstance.getProcess("authentikDeleteUser")).isNotNull();
|
||||
assertThat(t.qInstance.getProcess("authentikDeleteGroup").getMaxInputRecords()).isEqualTo(1);
|
||||
|
||||
AdminTestSupport off = AdminTestSupport.create();
|
||||
assertThat(off.qInstance.getProcess("authentikDeleteUser")).isNull();
|
||||
assertThat(off.qInstance.getProcess("authentikDeleteGroup")).isNull();
|
||||
assertThat(off.admin().execute(AdminRequest.of(AdminAction.DELETE_USER, "3", "x").with("confirmText", "sam")).ok()).isFalse();
|
||||
|
||||
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ, IdentityPermission.GROUPS_MANAGE);
|
||||
assertThat(run(AdminRequest.of(AdminAction.DELETE_USER, "3", "x").with("confirmText", "sam")).ok()).isFalse();
|
||||
assertThat(t.authentik.users).containsKey(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Edit User prefills username and (with audit.read) other attributes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void editUserPrefill() throws QException
|
||||
{
|
||||
t.authentik.users.get(3).put("attributes", new JSONObject().put("team", "web").put("qqq.io/x", 1));
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("authentikEditUser");
|
||||
input.addValue("recordIds", "3");
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getValues().get("username")).isEqualTo("sam");
|
||||
assertThat(new JSONObject(String.valueOf(output.getValues().get("otherAttributes"))).toMap()).containsOnlyKeys("team");
|
||||
|
||||
t.loginAs(AuthentikTestSupport.OPERATOR, IdentityPermission.USERS_READ, IdentityPermission.USERS_MANAGE);
|
||||
RunProcessInput limited = new RunProcessInput();
|
||||
limited.setProcessName("authentikEditUser");
|
||||
limited.addValue("recordIds", "3");
|
||||
assertThat(new RunProcessAction().execute(limited).getValues().get("otherAttributes")).isNull();
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ class QbitWriteContractIT
|
||||
|
||||
/** The phase 2/3 permissions of DESIGN.md section 5.1 (plus view_flow to resolve the invitation flow by slug). */
|
||||
private static final List<String> ROLE_PERMISSIONS = List.of(
|
||||
"authentik_core.add_user", "authentik_core.change_user", "authentik_core.reset_user_password",
|
||||
"authentik_core.add_user", "authentik_core.change_user", "authentik_core.reset_user_password", "authentik_core.delete_user", "authentik_core.delete_group",
|
||||
"authentik_core.add_group", "authentik_core.change_group", "authentik_core.add_user_to_group", "authentik_core.remove_user_from_group",
|
||||
"authentik_core.view_token", "authentik_core.delete_token", "authentik_core.delete_authenticatedsession",
|
||||
"authentik_stages_invitation.view_invitation", "authentik_stages_invitation.add_invitation", "authentik_stages_invitation.delete_invitation",
|
||||
@@ -166,7 +166,7 @@ class QbitWriteContractIT
|
||||
.withRecoveryEmailStage("kof22-recovery-email")
|
||||
.withInvitationFlow("wr-enrollment")
|
||||
.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP,
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES));
|
||||
AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS, AuthentikFeature.POLICIES, AuthentikFeature.DELETE));
|
||||
|
||||
qInstance = new QInstance();
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
@@ -345,6 +345,35 @@ class QbitWriteContractIT
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete user and delete group on a real Authentik (delete_user and
|
||||
** delete_group granted only for the DELETE feature); a group with members is
|
||||
** refused by the qbit before any call.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deleteUserAndGroup() throws Exception
|
||||
{
|
||||
admin.post("/core/users/", new JSONObject().put("username", "wr.leaver").put("name", "WR Leaver").put("email", "wr.leaver@example.test").put("type", "internal").put("path", "users"));
|
||||
int leaver = admin.userPk("wr.leaver");
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "wr-doomed"));
|
||||
String doomed = admin.groupPk("wr-doomed");
|
||||
admin.post("/core/groups/" + doomed + "/add_user/", new JSONObject().put("pk", leaver));
|
||||
|
||||
AuthentikAdmin.AdminResult notEmpty = run(AdminRequest.of(AdminAction.DELETE_GROUP, doomed, "contract test").with("confirmText", "wr-doomed"));
|
||||
assertThat(notEmpty.ok()).isFalse();
|
||||
assertThat(notEmpty.message()).contains("member(s)");
|
||||
|
||||
AuthentikAdmin.AdminResult user = run(AdminRequest.of(AdminAction.DELETE_USER, String.valueOf(leaver), "contract test").with("confirmText", "wr.leaver"));
|
||||
assertThat(user.ok()).as(user.message()).isTrue();
|
||||
assertThat(admin.get("/core/users/" + leaver + "/").status()).isEqualTo(404);
|
||||
|
||||
AuthentikAdmin.AdminResult group = run(AdminRequest.of(AdminAction.DELETE_GROUP, doomed, "contract test").with("confirmText", "wr-doomed"));
|
||||
assertThat(group.ok()).as(group.message()).isTrue();
|
||||
assertThat(admin.get("/core/groups/" + doomed + "/").status()).isEqualTo(404);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group create and edit (parents), and the superuser parent refused by the
|
||||
** guard.
|
||||
|
||||
Reference in new issue
Block a user