A COmanage Registry enrollment-flow plugin that gates self-service enrollment on a pre-issued access code. A valid code atomically adds the new CO Person to a mapped CO Group and lets the petition finalize; an invalid, expired, disabled, or exhausted code fails the petition with a specific user-facing message.
Requirements: COmanage Registry 4.x (CakePHP 2 line; built and tested against 4.1.2) on PostgreSQL. The uniqueness guarantee relies on a partial unique index that MySQL/MariaDB cannot express — see Install. Not compatible with the Registry 5.x rewrite.
Modeled on cilogon/EmailVerificationEnroller and the upstream ServiceEligibilityEnroller for the wedge-hook pattern. For the long-form reference (database schema, validation order, concurrency model, troubleshooting recipes) see doc.md.
Anonymous visitor
│
▼
Self-service enrollment flow
│
▼
selectOrgIdentity (SSO or anonymous)
│
▼
petitionerAttributes form
┌──────────────────────────────────┐
│ Given name: ____ │
│ Family name: ____ │
│ Email: ____ │
│ * Access Code: ____ (required, │
│ petition-only text field) │
└──────────────────────────────────┘
│ submit
▼
core CoPetitionsController persists CoPetitionAttribute rows
│
▼
AccessCodeEnrollerCoPetitionsController::execute_plugin_petitionerAttributes
│
│ BEGIN TRANSACTION
│ 1. Load petition + enrollee + petition attributes
│ 2. Load wedge config row
│ 3. Extract the AccessCode value (matched by CoEnrollmentAttribute.label)
│ 4. Normalize (optional strip_whitespace, always uppercase)
│ 5. AccessCode::findForLookup() — indexed SELECT scoped to CO + wedge
│ 6. Advisory checks for precise error messages:
│ status / valid_from / valid_through / max_uses / group exists
│ 7. AccessCode::consumeAtomically() — authoritative UPDATE
│ UPDATE ... WHERE (max_uses IS NULL OR use_count < max_uses)
│ AND status = 'A'
│ AND validity window is open
│ → affected rows must == 1
│ 8. addGroupMembership() — idempotent CoGroupMember insert
│ 9. Write CoPetitionHistoryRecord audit entry
│ COMMIT
│
▼
$this->redirect($onFinish); # advance to next wedge / finalize
Any failure in steps 1–9 throws; the parent CoPetitionsController catches it,
flashes the translated error message, writes a failure history record, and lands
the user on the petition error page. The transaction rolls back so use_count
is not burned on failures.
- Gate one enrollment flow with a code pool. The wedge runs at
petitionerAttributes, reads a required petition-only text field (default label:AccessCode), and validates it against codes scoped to the same CO + wedge. - Each code maps to a CO Group. A valid redemption adds the enrollee to the group with no manual approval step — in the same DB transaction as the atomic
use_countincrement. - Per-code limits, validity windows, and role expiry.
max_uses(NULL = unlimited),valid_from/valid_through(NULL = open-ended),status = Active | Suspended, and an optional stamp on the newCoPersonRole.valid_through(absoluterole_valid_throughor relativerole_valid_days) that wins over anyr:valid_throughthe flow sets. - Concurrency-safe.
consumeAtomically()is a single conditional UPDATE, somax_usesis enforced by the DB, not the application — exact even under racing submissions. - Append-only audit log (
cm_access_code_usages), with a UNIQUE guard on(co_petition_id, access_code_id)that doubles as the double-submit dedup guard and the usage history admins see on each code's view page. - Case-insensitive lookup + admin-generated codes. Codes normalize to uppercase (and optionally whitespace-stripped). Leave the Code field blank to auto-generate one; the default charset excludes look-alikes (
0/O,1/I/L).
# 1. Drop the plugin into the registry
cp -R AccessCodeEnroller /path/to/comanage-registry/local/Plugin/
# 2. Create the three tables (cm_access_code_enrollers,
# cm_access_codes, cm_access_code_usages)
cd /path/to/comanage-registry/app
./Console/cake database
# 3. Apply the partial unique index (post-install DDL;
# PostgreSQL-only, required -- see note below)
psql -U <registry_user> -d <registry_db> \
-f /path/to/comanage-registry/local/Plugin/AccessCodeEnroller/Config/Schema/partial_indexes.sql
# 4. Clear the Cake model cache so the new tables are picked up
rm -f app/tmp/cache/models/cake_model_default_cm_access_code_enrollers \
app/tmp/cache/models/cake_model_default_cm_access_codes \
app/tmp/cache/models/cake_model_default_cm_access_code_usages \
app/tmp/cache/persistent/cake_core_object_map \
app/tmp/cache/persistent/cake_core_file_map
# 5. Enable the plugin (Platform -> Plugins in the admin UI,
# or POST to /registry/co_plugins/activate)About the partial index: cm_access_codes uses COmanage's Changelog behavior, which inserts an archive revision row on every edit. A plain UNIQUE(co_id, code) collides with those archive rows and breaks every edit with SQLSTATE 23505. The partial index in Config/Schema/partial_indexes.sql excludes archive and soft-deleted rows, giving DB-atomic uniqueness on the live row only. CakePHP's schema.xml can't express partial indexes, which is why this lives in a separate DDL file. Skip this step and edits will work exactly once, then explode.
CO Configuration → Enrollment Flows → Add
Add a required Enrollment Attribute:
- Label:
AccessCode - Type: Text Field (Petition Use Only)
- Required: yes
- Modifiable: yes (critical — a "not modifiable" field is not submitted as form data, and the wedge will fail with a missing-code error)
In the same enrollment flow, add an Enrollment Flow Wedge of type AccessCodeEnroller at step petitionerAttributes. Edit the wedge and set:
| Field | Meaning |
|---|---|
| Attribute Label | Must match the label of the petition-only text field you just added. Defaults to AccessCode. |
| Strip Whitespace | If checked, whitespace is removed from submitted codes before lookup. Case-insensitivity is always on. |
| Allow Re-enrollment | If checked, a petitioner who is already in the target group succeeds silently rather than hitting "already a member". |
All code-generation settings (length, charset, prefix, max uses, validity window) live on the per-code Add form, not on the wedge — those values are properties of the code, not of the enrollment flow.
CO Sidebar → Access Codes → Add
| Field | Notes |
|---|---|
| CO Group | Target group the enrollee will be added to. |
| Code | Leave blank to auto-generate. Typed codes are uppercased and whitespace-stripped before storage. |
| Auto-generate Length / Charset / Prefix | Only shown on Add. Defaults to 16 chars, Crockford-ish 31-char alphabet, no prefix. |
| Label / Description | Admin-facing only. |
| Status | Active or Suspended. Suspended codes fail with disabled_code. |
| Max Uses | Blank = unlimited. |
| Valid From / Valid Through | Blank = open-ended. Free-form; parsed by strtotime(), stored as UTC. |
| Role Valid Through | Optional. Absolute datetime to stamp on the resulting CoPersonRole.valid_through. Overrides any r:valid_through the flow itself would set. Mutually exclusive with Role Valid Days. |
| Role Valid Days | Optional. Stamps the role's valid_through at now() + N days at redemption time. Mutually exclusive with Role Valid Through. |
The code is never stored on the CO Person — only in cm_access_codes and (as a copy of the submitted value) in the per-petition attribute row.
They fill out the normal enrollment form with one extra required field (label: "Access Code"). On submit:
- Valid code → petition advances, they're added to the mapped CO Group, and (if the code configures
role_valid_through/role_valid_days) their new role gets itsvalid_throughstamped. Normal success page. - Invalid / expired / disabled / exhausted / misconfigured → petition error page with a specific translated message telling them what happened (e.g. "That access code expired on 2026-12-15 04:00 UTC").
Admins can see the redemption history on each code's view page — a table of "when / person / petition" joins back to cm_access_code_usages.
AccessCodeEnroller/
├── Config/Schema/
│ ├── schema.xml # access_code_enrollers + access_codes + access_code_usages
│ └── partial_indexes.sql # post-install DDL: partial UNIQUE on live (co_id, code)
├── Controller/
│ ├── AccessCodeEnrollerAppController.php # shared base controller
│ ├── AccessCodeEnrollerCoPetitionsController.php # THE WEDGE HOOK (execute_plugin_petitionerAttributes)
│ ├── AccessCodeEnrollersController.php # SEWController -- wedge config CRUD
│ └── AccessCodesController.php # StandardController -- code pool CRUD
├── Lib/
│ └── lang.php # every _txt() string the plugin uses
├── Model/
│ ├── AccessCodeEnrollerAppModel.php # shared base model
│ ├── AccessCodeEnroller.php # wedge config row, generateRandomCode, cmPluginMenus
│ ├── AccessCode.php # normalize, findForLookup, consumeAtomically, beforeValidate
│ └── AccessCodeUsage.php # append-only redemption audit row
├── View/
│ ├── AccessCodeEnrollers/
│ │ ├── edit.ctp
│ │ └── fields.inc # 3-field wedge config form
│ └── AccessCodes/
│ ├── add.ctp
│ ├── edit.ctp
│ ├── view.ctp
│ ├── index.ctp # code pool listing
│ └── fields.inc # full code form + usage subtable on view
├── LICENSE # Apache 2.0
├── README.md
├── VERSION
└── doc.md # long-form reference
AccessCode::consumeAtomically() is a single conditional UPDATE:
UPDATE cm_access_codes
SET use_count = use_count + 1,
last_used = :now,
modified = :now
WHERE id = :id
AND (deleted IS NULL OR deleted IS NOT TRUE)
AND status = 'A'
AND (max_uses IS NULL OR use_count < max_uses)
AND (valid_from IS NULL OR valid_from <= :now)
AND (valid_through IS NULL OR valid_through >= :now)The whole wedge hook runs in one DB transaction — lookup, consume, usage-row insert, group-member insert, role stamp, history record, commit. Any failure rolls the consume back so use_count is not burned on partial failures. Under concurrent submissions, max_uses is authoritatively enforced by the DB, not by the application-level advisory checks (which exist only to produce precise error messages).
The UNIQUE (co_petition_id, access_code_id) index on cm_access_code_usages is the dedup guard for the same user double-submitting the petition form: the second INSERT hits a 23505, the outer catch detects it, and the second request is treated as idempotent success.
Deliberate trade-offs for v1:
- Plaintext code storage. Admins need to view and export codes for distribution; DB-level protection is assumed.
- No rate limiting, no failed-attempt logging. Entropy is the only defense against guessing. Default config (16 chars × 31-char alphabet) is ~79 bits — raise the length in the Add form for stronger thresholds.
- Vague
unknown/malformed/ambiguousmessages so attackers can't distinguish failure modes. - Plaintext codes are never logged.
$this->log()records the candidate rowidwhere relevant. - SQL injection — all queries go through the Cake ORM with bound parameters. The raw UPDATE in
consumeAtomically()uses named placeholders. - Authorization — all admin actions require
cmadminorcoadmin.
- Plaintext storage, no rate limiting, no failed-attempt logging. Documented trade-offs, not bugs.
- Bulk code creation is not a first-class feature. Add codes one at a time through the UI, or insert directly into
cm_access_codes(pre-normalizecode, setco_idandaccess_code_enroller_id). - Role-validity stamping only affects the single
CoPersonRolethe petition creates. It does not touch theCoGroupMemberrow — group memberships remain open-ended unless the admin bounds them separately. The code expires the role, not the group.
Apache License, Version 2.0. See LICENSE.