Skip to content

How-to: Group Member Management

FT reference: FT291 (NENE2-FT/grouplog) — Group membership: MemberRole enum (owner/admin/member), UNIQUE(group_id, user_id), owner-cannot-be-removed guard, cross-group IDOR prevention, canManageMembers()/canChangeRoles() role hierarchy, VULN-A~L all SAFE, 38 tests / 101 assertions PASS.

This guide shows how to build a group management system with role-based membership control — owners, admins, and members with graduated permissions.

Schema

sql
CREATE TABLE user_groups (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT    NOT NULL,
    owner_id   INTEGER NOT NULL,
    created_at TEXT    NOT NULL,
    FOREIGN KEY (owner_id) REFERENCES users(id)
);

CREATE TABLE memberships (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    group_id  INTEGER NOT NULL,
    user_id   INTEGER NOT NULL,
    role      TEXT    NOT NULL DEFAULT 'member',
    joined_at TEXT    NOT NULL,
    UNIQUE (group_id, user_id),
    CHECK (role IN ('owner', 'admin', 'member')),
    FOREIGN KEY (group_id) REFERENCES user_groups(id),
    FOREIGN KEY (user_id)  REFERENCES users(id)
);

UNIQUE(group_id, user_id) prevents duplicate memberships. CHECK(role IN ...) blocks invalid roles at DB level.

Endpoints

MethodPathAuthDescription
POST/groupsX-User-IdCreate group (actor becomes owner)
GET/groups/{groupId}/membersX-User-Id (member)List members
POST/groups/{groupId}/membersX-User-Id (owner/admin)Add member
DELETE/groups/{groupId}/members/{userId}X-User-IdRemove member
PUT/groups/{groupId}/members/{userId}/roleX-User-Id (owner)Change role

MemberRole Enum

php
enum MemberRole: string
{
    case Owner  = 'owner';
    case Admin  = 'admin';
    case Member = 'member';

    public function canManageMembers(): bool
    {
        return $this === self::Owner || $this === self::Admin;
    }

    public function canChangeRoles(): bool
    {
        return $this === self::Owner;
    }
}

Role capabilities:

  • Owner: can add/remove members, change roles, cannot be removed
  • Admin: can add/remove members, cannot change roles
  • Member: can only leave (remove self)

Actor Resolution

php
private function resolveActorId(ServerRequestInterface $request): int
{
    $header = $request->getHeaderLine('X-User-Id');
    return is_numeric($header) ? (int) $header : 0;
}

Non-numeric headers return 0 (invalid). Every privileged operation validates the actor against the DB before proceeding.

Membership Check Before Any Operation

php
$actorMembership = $actorId > 0 ? $this->repo->findMembership($groupId, $actorId) : null;

if ($actorMembership === null) {
    return $this->responseFactory->create(['error' => 'not a member'], 403);
}

Non-members get 403 on all group operations — including listing members (IDOR prevention).

Adding Members — Role Hierarchy

php
$actorRole = MemberRole::tryFrom($actorMembership['role']) ?? MemberRole::Member;

if (!$actorRole->canManageMembers()) {
    return $this->responseFactory->create(['error' => 'only owner or admin can add members'], 403);
}

// Cannot assign 'owner' via add-member endpoint
$role = MemberRole::tryFrom($roleValue);
if ($role === null || $role === MemberRole::Owner) {
    return $this->responseFactory->create(['error' => 'role must be member or admin'], 422);
}

owner role cannot be assigned via the API — it is set only at group creation.

Owner Cannot Be Removed

php
$targetRole = MemberRole::tryFrom($targetMembership['role']) ?? MemberRole::Member;

if ($targetRole === MemberRole::Owner) {
    return $this->responseFactory->create(['error' => 'cannot remove the group owner'], 422);
}

The owner is protected from removal. Ownership transfer would require a dedicated endpoint.

Self-Leave vs. Admin Remove

php
$isSelfLeave = $actorId === $userId;

if (!$isSelfLeave && !$actorRole->canManageMembers()) {
    return $this->responseFactory->create(['error' => 'only owner or admin can remove members'], 403);
}

Members can remove themselves (self-leave) without admin rights. Removing another user requires canManageMembers().

Role Change — Owner Only

php
if (!$actorRole->canChangeRoles()) {
    return $this->responseFactory->create(['error' => 'only owner can change roles'], 403);
}

$role = MemberRole::tryFrom($roleValue);
if ($role === null || $role === MemberRole::Owner) {
    return $this->responseFactory->create(['error' => 'role must be member or admin'], 422);
}

Only the owner can promote/demote members. The owner role cannot be assigned (preventing silent ownership theft).


Vulnerability Assessment

V-01 — IDOR: Non-member reads member list ✅ SAFE

Risk: Non-member calls GET /groups/{id}/members to enumerate users. Finding: SAFE — findMembership(groupId, actorId) === null → 403 before returning any data.


V-02 — IDOR: Non-member adds someone to a group ✅ SAFE

Risk: Non-member calls POST /groups/{id}/members to inject users. Finding: SAFE — same membership check; non-member → 403.


V-03 — Privilege Escalation: regular member adds another member ✅ SAFE

Risk: Regular member (role = 'member') tries to add a new user. Finding: SAFE — canManageMembers() returns false for Member → 403.


V-04 — Privilege Escalation: admin promotes to owner ✅ SAFE

Risk: Admin tries to assign role = 'owner' via add-member or change-role endpoints. Finding: SAFE — both endpoints reject MemberRole::Owner as a valid assignable role → 422.


V-05 — Privilege Escalation: member promotes self ✅ SAFE

Risk: Regular member calls PUT /groups/{id}/members/{self}/role. Finding: SAFE — canChangeRoles() returns false for Member and Admin → 403.


V-06 — Owner Removal ✅ SAFE

Risk: Admin tries to remove the group owner. Finding: SAFE — if ($targetRole === MemberRole::Owner) → 422.


V-07 — Missing X-User-Id on group creation ✅ SAFE

Risk: Request without X-User-Id creates a group with no valid owner. Finding: SAFE — resolveActorId() returns 0 for missing/invalid header → findUserById(0) returns null → 404.


V-08 — Non-numeric X-User-Id ✅ SAFE

Risk: Header X-User-Id: admin bypasses numeric actor validation. Finding: SAFE — is_numeric($header) returns false for non-numeric strings → returns 0 → rejected.


V-09 — SQL injection in group name ✅ SAFE

Risk: Group name '; DROP TABLE user_groups; -- deletes data. Finding: SAFE — all queries use parameterized statements. Injection string is stored verbatim as the group name without execution.


V-10 — Cross-group member operation (IDOR) ✅ SAFE

Risk: Owner of group A tries to remove a member from group B. Finding: SAFE — findMembership(groupId, actorId) checks membership in the target group. Owner of group A has no membership in group B → 403.


V-11 — Negative group ID ✅ SAFE

Risk: GET /groups/-1/members causes DB error or unexpected behavior. Finding: SAFE — is_numeric($params['groupId']) ? (int)$params['groupId'] : 0 accepts -1 as numeric, but findGroupById(-1) returns null → 404.


V-12 — Admin cannot change roles ✅ SAFE

Risk: Admin calls PUT /groups/{id}/members/{userId}/role to promote users. Finding: SAFE — canChangeRoles() is owner-only → admin gets 403.


VULN Summary

IDVulnerabilityFinding
V-01IDOR: non-member reads member list✅ SAFE
V-02IDOR: non-member adds member✅ SAFE
V-03Privilege escalation: member adds member✅ SAFE
V-04Privilege escalation: admin → owner✅ SAFE
V-05Privilege escalation: member promotes self✅ SAFE
V-06Owner removal✅ SAFE
V-07Missing X-User-Id on create✅ SAFE
V-08Non-numeric X-User-Id✅ SAFE
V-09SQL injection in group name✅ SAFE
V-10Cross-group IDOR (owner of other group)✅ SAFE
V-11Negative group ID✅ SAFE
V-12Admin cannot change roles✅ SAFE

12 SAFE, 0 EXPOSED Membership check before every operation, canManageMembers()/canChangeRoles() role hierarchy, and owner-removal guard prevent all privilege escalation and IDOR vectors.


What NOT to do

Anti-patternRisk
No membership check before listing membersNon-members enumerate all group users (IDOR)
Allow owner role assignment via add-memberAny admin can silently take ownership
Allow owner role assignment via change-roleSame — ownership theft with one request
Skip canManageMembers() checkRegular members add/remove anyone
Allow owner removalGroup loses its governing user
No UNIQUE(group_id, user_id)Same user added twice; duplicate membership records
is_numeric() check only for X-User-Id"1.5" passes is_numeric; use (int) cast + validate against DB
Check membership in actor's own group (not target group)Cross-group IDOR: owner of group A modifies group B
Allow admin to change rolesAdmin self-promotes to owner; role hierarchy bypass

Released under the MIT License.