A COmanage Registry enrollment-flow plugin that gates self-service enrollment on a pre-issued access code. A valid code adds the new CO Person to a mapped CO Group and lets the petition auto-finalize; an invalid, expired, or exhausted code fails the petition with a specific user-facing message.
This document is the long-form reference. For a quickstart, see README.md.
| Term | Meaning |
|---|---|
| Wedge config | One row in cm_access_code_enrollers. Belongs to a single CoEnrollmentFlowWedge. Holds per-wedge runtime behavior flags only (attribute label, strip_whitespace, allow_reenrollment). |
| Access code | One row in cm_access_codes. Belongs to a wedge config and a CO Group. Carries the code value, validity window, usage counters, and status. |
| Target group | The CoGroup the enrollee is added to when a code is consumed. One code → one group; a group may have many codes. |
| Petition-only attribute | A CoEnrollmentAttribute of type "Text Field (Petition Use Only)" whose value lives on cm_co_petition_attributes and is never copied to the CoPerson. The access code is collected this way. |
Invariants (not configurable):
- Codes are always matched case-insensitively.
normalize()uppercases on read and on write, and the unique index(co_id, UPPER(code))is effectively enforced because stored codes are always already uppercase. - A code is scoped to exactly one CO via
co_idand to exactly one wedge config viaaccess_code_enroller_id. use_countis only ever updated viaconsumeAtomically()— a conditionalUPDATEthat is the single source of truth formax_usesenforcement.
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.
Tables are created by the COmanage schema shell from Config/Schema/schema.xml
with the cm_ prefix added automatically.
One row per CoEnrollmentFlowWedge configured to use this plugin.
| Column | Type | Notes |
|---|---|---|
id |
int PK | |
co_enrollment_flow_wedge_id |
int | FK → cm_co_enrollment_flow_wedges.id |
access_code_attribute_label |
varchar(64) | Label of the petition-only text field holding the code. Defaults to AccessCode. |
strip_whitespace |
bool | If true, collapse all whitespace before lookup. Default true. |
allow_reenrollment |
bool | If true, a petitioner who is already in the target group succeeds silently instead of failing. Default true. |
created, modified |
timestamp | |
access_code_enroller_id, revision, deleted, actor_identifier |
— | Changelog behavior columns. |
One row per individual code.
| Column | Type | Notes |
|---|---|---|
id |
int PK | |
access_code_enroller_id |
int | FK → cm_access_code_enrollers.id. Scopes code to a wedge config. |
co_id |
int | FK → cm_cos.id. Scopes code to a CO. Stamped by the Add form via hidden input. |
co_group_id |
int | FK → cm_co_groups.id. Target group the enrollee is added to. |
code |
varchar(128) | The code. Stored uppercased + whitespace-stripped. |
label |
varchar(128) | Human label, e.g. "CHTC Fall 2026 workshop cohort A". |
description |
text | Free-text admin notes. |
status |
varchar(2) | A (active) or S (suspended). |
max_uses |
int | NULL = unlimited. |
use_count |
int | Incremented only by consumeAtomically(). |
valid_from |
timestamp | NULL = immediately valid. UTC. |
valid_through |
timestamp | NULL = never expires. UTC, inclusive. |
last_used |
timestamp | Stamped on successful consume. |
role_valid_through |
timestamp | Optional. If set, the wedge hook stamps the resulting CoPersonRole.valid_through with this absolute datetime at redemption. UTC. Mutually exclusive with role_valid_days. See §6a. |
role_valid_days |
int | Optional. If set, the wedge hook stamps CoPersonRole.valid_through with now() + N days at redemption. Mutually exclusive with role_valid_through. See §6a. |
created, modified |
timestamp | |
access_code_id, revision, deleted, actor_identifier |
— | Changelog behavior columns. |
Mutual exclusion between role_valid_through and role_valid_days is
enforced at save time in AccessCode::beforeValidate() — the second field
set triggers a form validation error and the row never reaches the DB.
Indexes:
access_codes_i_code— partial unique index on(co_id, code)WHERE access_code_id IS NULL AND deleted IS NOT TRUE. Because stored codes are always canonical-form (uppercase, no whitespace), this acts as a case-insensitive uniqueness constraint within a CO. The partial filter is required because this table usesChangelogbehavior — see the boxed note below.access_codes_i1onaccess_code_enroller_idaccess_codes_i2onco_group_idaccess_codes_i3onstatusaccess_codes_i4onaccess_code_id(speeds up Changelog archive lookups)
Why the partial unique index
Changelogbehavior turns every edit into: insert an archive copy of the old row (same(co_id, code)but withaccess_code_idpointing at the live row's id), then update the live row. A plain unique index on(co_id, code)would collide with the archive row and crash every edit with SQLSTATE 23505. The partial filteraccess_code_id IS NULL AND deleted IS NOT TRUEexcludes archive rows and soft-deleted rows from the uniqueness scope, so only the live row is constrained — which is what we actually want. CakePHP'sschema.xmlcan't express partial indexes, so the plain index is created by the schema shell and the partial index is applied as a post-install step fromConfig/Schema/partial_indexes.sql.
One row per successful consume. Append-only: never updated, never edited,
not Changelog-tracked. Serves two purposes:
- Admin audit trail — the history table rendered on the code view page.
- Double-submit dedup guard — the
UNIQUE (co_petition_id, access_code_id)index makes concurrent double-submits of the same petition form idempotent. The wedge hook pre-checks this table before callingconsumeAtomically()to avoid burning a wasteduse_countincrement on the common double-click, and also re-checks it inside thecatchblock to detect the narrow micro-race where two requests for the same(petition, code)interleave between the pre-check and the insert.
| Column | Type | Notes |
|---|---|---|
id |
int PK | |
access_code_id |
int | FK → cm_access_codes.id |
co_petition_id |
int | FK → cm_co_petitions.id |
co_person_id |
int | FK → cm_co_people.id. The enrollee on the petition. |
used_at |
timestamp | UTC. Stamped inside the same transaction as consumeAtomically(). |
created, modified |
timestamp | Cake convention; used_at is the authoritative redemption timestamp. |
Indexes:
access_code_usages_i1onaccess_code_idaccess_code_usages_i2onco_petition_idaccess_code_usages_i3onco_person_idaccess_code_usages_i_petcode— UNIQUE(co_petition_id, access_code_id)— the dedup guard
Because this table has no Changelog columns, a plain UNIQUE index works
without needing the partial-filter workaround that cm_access_codes uses.
- 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" enrollment attribute
is rendered read-only and its submitted value is dropped from the
form data, so the wedge hook sees nothing in
CoPetitionAttributefor that label and fails wither.missing_code. See §10 for the full symptom/fix. This also applies to any other required attributes on the flow (e.g.r:affiliation) — if they're flagged "not modifiable", the CoPersonRole core creates at the start ofpetitionerAttributesmay be missing required fields, which then trips our role-stampsave()with a cascade of "required field" validation errors. Leave required attributes modifiable.
- Label:
-
In the same enrollment flow, add an Enrollment Flow Wedge of type AccessCodeEnroller at step
petitionerAttributes. -
Edit the wedge and set the three fields:
Field Meaning Attribute Label Must match the label of the petition-only text field you just added ( AccessCodeby default).Strip Whitespace If checked, all whitespace is stripped from the submitted code before lookup. Case-insensitivity is always on. Allow Re-enrollment If checked, a petitioner who is already a member of the target group succeeds silently instead of 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. This is deliberate — those values are properties of the code itself, not of the enrollment flow.
CO Sidebar → Access Codes → Add
| Field | Notes |
|---|---|
| Enrollment Flow Wedge | Hidden when the CO has exactly one AccessCodeEnroller wedge (the dominant case). Shown as a dropdown when multiple wedges exist. |
| CO Group | The group the enrollee will be added to. |
| Code | Leave blank to auto-generate using the length / charset / prefix fields below. If you type a code, it is uppercased and whitespace-stripped before storage. |
| Auto-generate Length | Defaults to 16. |
| Auto-generate Character Set | Defaults to ABCDEFGHJKMNPQRSTUVWXYZ23456789 (Crockford-ish — no 0, O, 1, I, L). |
| Auto-generate Prefix | Optional, e.g. CHTC26-. |
| Label | Short human-readable name, e.g. "CHTC Fall 2026 cohort A". |
| Description | Longer admin notes. |
| Status | Active or Suspended. Suspended codes fail with disabled_code. |
| Max Uses | Blank = unlimited. |
| Valid From | Blank = immediately valid. Free-form; parsed by strtotime(). |
| Valid Through | Blank = never expires. Free-form; parsed by strtotime(). |
| Role Valid Through | Optional absolute datetime to stamp on the resulting CoPersonRole.valid_through at redemption time. Overrides any r:valid_through the flow itself would have set. Mutually exclusive with Role Valid Days. UTC. See §6a. |
| Role Valid Days | Optional. If set, the wedge hook stamps CoPersonRole.valid_through = now() + N days at redemption. Mutually exclusive with Role Valid Through. See §6a. |
The transient code_length, code_charset, code_prefix fields are not
persisted — they are read in AccessCode::beforeSave() and then unset before
the row hits the DB.
The hook runs validation in this order. First failure wins.
| Step | Failure | Text key | User sees |
|---|---|---|---|
| 1 | No value for the access-code attribute | er.missing_code |
"An access code is required. Please enter the code you were given." |
| 2 | Value longer than 128 chars after normalize | er.malformed_code |
"That access code is not recognized. Please double-check it and try again." |
| 3 | No matching row | er.unknown_code |
(same as malformed) |
| 4 | >1 matching row (shouldn't happen — defensive) | er.ambiguous_code |
"That access code cannot be processed. Please contact support." |
| 5 | status != 'A' |
er.disabled_code |
"That access code has been disabled. Please contact the person who gave it to you." |
| 6 | now < valid_from |
er.code_not_yet_valid |
"That access code is not yet active. It becomes valid on 2026-09-01 04:00 UTC." |
| 7 | now > valid_through |
er.code_expired |
"That access code expired on 2026-12-15 04:00 UTC." |
| 8 | use_count >= max_uses (advisory) |
er.code_exhausted |
"That access code has reached its maximum number of uses." |
| 9 | Target group missing / deleted / wrong CO | er.group_misconfigured |
"This access code is misconfigured. Please contact support." |
| 10 | Atomic UPDATE affected != 1 (race lost) | er.code_exhausted |
(same as advisory exhausted) |
| 11 | allow_reenrollment = false and petitioner already a member |
er.already_member |
"You are already a member of the target group." |
| 12 | AccessCodeUsage save failure (non-dedup) |
er.internal |
"An unexpected error occurred. Please try again later." |
| 13 | CoPersonRole->saveField('valid_through', …) failure |
er.internal |
(same) — transaction rolls back, use_count not burned |
| 14 | Wedge config row missing | er.plugin_unconfigured |
"Enrollment is temporarily unavailable. Please try again later." |
| 15 | Enrollee CoPerson missing on the petition | er.no_enrollee |
"The petition is missing an enrollee record. Please contact support." |
| 16 | Any other save/DB failure | er.internal |
"An unexpected error occurred. Please try again later." |
Dedup-race fast path: if the wedge's pre-check finds a pre-existing
cm_access_code_usages row for (petition, code), the wedge commits the
current (empty) transaction and redirects to $onFinish without re-running
the consume / membership / role-stamp work. The same check runs again
inside the catch block to detect the narrow interleaving where a second
request's INSERT loses the UNIQUE race — in that case the first request
has already done the work and we return idempotent success rather than
surfacing an error the petitioner shouldn't see.
Messages for "unknown", "malformed", and "ambiguous" are intentionally collapsed so guessers can't distinguish "doesn't exist" from "close but wrong". "Disabled", "not yet valid", "expired", and "exhausted" remain distinct because they're useful for legitimate users who need to diagnose their own failure.
All text keys live in Lib/lang.php under the pl.access_code_enroller.*
namespace.
AccessCode::consumeAtomically($id) is the single source of truth for
max_uses enforcement:
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 PHP-side advisory checks (steps 5–8 above) exist only to produce precise
error messages. The
WHEREclause repeats every check so the DB is authoritative under concurrent submissions. - If
lastAffected() != 1, the caller treats the race as lost and throwser.code_exhausted. This is consistent: the user's code either made it past the gate or did not. - The entire
execute_plugin_petitionerAttributeshook runs in onebegin / commit / rollbackso that a failure to create theCoGroupMemberrow or write the history record undoes theuse_countincrement.
Guarantees under concurrency:
- Two petitioners consuming the same code with
max_uses = KandKslots remaining → at mostKgroup memberships created anduse_count == K. - Two petitioners consuming different codes that both point to the same group → both succeed; the group-member check is idempotent.
Double-submit protection (v1.0 extension): cm_access_code_usages has a
UNIQUE (co_petition_id, access_code_id) index and the wedge hook writes a
row inside the same transaction as consumeAtomically(). The hook also
pre-checks the table before the consume so the common case (user
double-clicks Submit) short-circuits to commit-and-redirect without burning
a wasted use_count increment. The catch block re-checks after rollback to
catch the narrow micro-race where two requests interleave. Net effect: the
same petition can redeem the same code exactly once, even under
concurrent submissions.
A code can optionally stamp the resulting CoPersonRole.valid_through at
redemption time using one of two mutually-exclusive fields:
role_valid_through— an absolute UTC datetime. Use for "this code grants access through 2026-12-15".role_valid_days— an integer. Use for "this code grants N days from the moment it's redeemed". Anchored onnow()at redemption, not onvalid_fromorcreated.
Mutual exclusion is enforced in AccessCode::beforeValidate(); the admin
form rejects a row with both fields set.
The two pairs of datetime fields mean different things:
| Field pair | Meaning |
|---|---|
valid_from / valid_through on cm_access_codes |
When the code itself can be redeemed. Outside this window the hook fails with code_not_yet_valid / code_expired. |
role_valid_through / role_valid_days on cm_access_codes |
When the CoPersonRole the redemption creates stays active. Stamped on the role row after the code is successfully consumed. |
The two windows are fully decoupled. A code can have valid_through = 2026-12-15
(must be redeemed by that date) and role_valid_through = 2027-06-30 (but
the resulting role stays active until mid-2027). Reversing them is also
legitimate: valid_through = 2026-12-15 (redeem by that date) and
role_valid_days = 30 (role lives 30 days from redemption, regardless of
when redemption happens within the code's validity window).
core CoPetitionsController::saveAttributes()
├─ persists CoPetitionAttribute rows
├─ creates the CoPersonRole row (honoring r:valid_from / r:valid_through /
│ r:affiliation etc. enrollment attributes if present)
└─ records the role id on CoPetition.enrollee_co_person_role_id
│
▼
dispatches to our AccessCodeEnrollerCoPetitionsController::execute_plugin_petitionerAttributes
├─ validates + consumes the code
├─ writes AccessCodeUsage row
├─ adds CoGroupMember
├─ if code configures role_valid_through or role_valid_days:
│ → $this->CoPersonRole->saveField('valid_through', $validThrough)
│ ONLY on the role id core just stamped onto CoPetition
└─ writes CoPetitionHistoryRecord
The role is created by core before the wedge runs, so
CoPetition.enrollee_co_person_role_id is already populated when we
arrive. We never have to create a role — only update the existing one.
If the enrollment flow has an r:valid_through CoEnrollmentAttribute, core
populates the role's valid_through as part of saveAttributes(). Our
wedge runs immediately afterward and overwrites it. The code's
configured window always wins over any default the flow itself would
have set. This is intentional: the code is the authoritative grant; the
flow's default is a fallback for codes that don't configure one.
Admins who want the flow's r:valid_through default should simply not
set role_valid_through / role_valid_days on the code.
CoPersonRole's model validation marks several sibling fields
(co_person_id, status, affiliation) as required => true. Those
rules fire on partial updates too unless the save is restricted to a
specific fieldList. Passing ['id' => X, 'valid_through' => Y] to
save() trips those rules with errors like "A CO Person ID must be provided" even though the fields in question already have valid values
on disk — Cake 2 doesn't cross-check the DB during validation.
saveField() solves this by internally passing fieldList=[valid_through]
to save(), scoping validation to only the field being updated. It still
goes through Model::save() under the hood, so Changelog fires and the
prior row is archived for audit.
The hook uses:
$this->CoPersonRole->clear();
$this->CoPersonRole->id = $roleId;
$roleOk = $this->CoPersonRole->saveField('valid_through', $validThrough);Setting $this->CoPersonRole->id before saveField() ensures Cake treats
the call as an update (not an insert). Forgetting this is a silent
disaster.
Only valid_through is stamped. valid_from is left at whatever the flow
produced (usually NULL, unless there's an r:valid_from attribute).
"Open-start / fixed-end" is a supported shape. Admins who want a specific
grant-start convention should set an r:valid_from attribute on the flow
itself.
- Flow doesn't produce a role. Rare for an access-code-gated flow but
possible with a "group membership only" enrollment. If
CoPetition.enrollee_co_person_role_idis NULL when we arrive, the wedge logs a warning, skips the stamp, and proceeds. It does NOT fail the redemption — the group membership is the thing that matters. - Past datetime. Setting
role_valid_throughto yesterday produces a role withvalid_throughin the past. Core'sCoPersonRole::beforeSave()auto-transitions the status toExpired. No special handling needed. r:valid_fromin the future + coderole_valid_throughearlier than that. The resulting row would violateCoPersonRole'svalid_from <= valid_throughinvariant on disk, but because we submit onlyvalid_throughviasaveField(), the cross-fieldprecedes/followsrule never fires and the write goes through. This is a documented misconfiguration trap — admins who mix flow-setr:valid_fromwith code-setrole_valid_throughtake responsibility for keeping them ordered.- Role save failure. If the partial update fails (validation trip on
valid_throughitself, or a DB error), aRuntimeExceptionis thrown. The outer transaction rolls back, souse_countis not incremented and noAccessCodeUsage,CoGroupMember, or history record is written. Failure here is transparent to the rest of the audit trail.
Deliberate trade-offs for v1:
- Plaintext codes. Admins need to view and export codes for distribution, so they are stored in cleartext. Database access is assumed to be already protected.
- No rate limiting, no failed-attempt logging. Code entropy is the only defense against guessing. Default config: length 16, 31-char Crockford-ish charset → ~79 bits of entropy. Raise the length in the Add form if your threat model requires more.
- Look-alike characters excluded from the default charset (
0,O,1,I,L) to avoid user-transcription ambiguity. - Hard cap of 128 chars on the normalized code value to bound pathological inputs.
- Vague failure messages for
unknown/malformed/ambiguousso attackers can't distinguish failure modes. - No plaintext logging.
$this->log()records the candidate rowidwhere relevant; the raw code is never written to the Cake / PHP log. - SQL injection — all queries go through the Cake ORM with bound
parameters. The one raw UPDATE in
consumeAtomically()uses named placeholders. - Authorization — all admin actions require
cmadminorcoadmin.
Edit the code, set Status = Suspended. Subsequent attempts fail with
disabled_code. The row and its use_count history are preserved.
Create with Valid From and Valid Through matching the event window. Codes
outside the window fail with code_not_yet_valid / code_expired, both of
which surface the actual date to the user.
- Suspend the original code (don't delete — you want the
use_countandlast_usedfor the audit trail). - Create a replacement code pointing at the same group.
- Distribute the new code out-of-band.
Not a first-class feature in v1. Either:
- Add them one at a time through the UI (auto-generate fills the code), or
- Insert rows directly into
cm_access_codesvia SQL, making sure to pre-normalize (UPPER(code), whitespace-stripped) and to setaccess_code_enroller_idandco_idcorrectly.
Set max_uses = 1. The first successful consume exhausts the code.
Keep allow_reenrollment = true on the wedge (default). Re-enrollment is a
silent no-op that still burns a use on the code. Set it to false if you want
an already-a-member error.
AccessCodeEnroller/
├── Config/Schema/
│ ├── schema.xml # table definitions (cake schema shell)
│ └── partial_indexes.sql # post-install DDL: partial unique index on (co_id, code)
├── Controller/
│ ├── AccessCodeEnrollerAppController.php # shared base controller
│ ├── AccessCodeEnrollerCoPetitionsController.php # THE WEDGE HOOK
│ ├── AccessCodeEnrollersController.php # SEWController — wedge config CRUD
│ └── AccessCodesController.php # StandardController — code pool CRUD
├── Lib/
│ └── lang.php # all _txt() strings
├── Model/
│ ├── AccessCodeEnrollerAppModel.php # shared base model
│ ├── AccessCodeEnroller.php # wedge config, generateRandomCode, cmPluginMenus
│ ├── AccessCode.php # normalize, findForLookup, consumeAtomically, beforeValidate
│ └── AccessCodeUsage.php # append-only redemption audit row
├── View/
│ ├── AccessCodeEnrollers/
│ │ ├── edit.ctp # scaffold edit page
│ │ └── fields.inc # wedge config form (3 fields)
│ └── AccessCodes/
│ ├── add.ctp # scaffold add page
│ ├── edit.ctp # scaffold edit page
│ ├── view.ctp # scaffold view page (renders usage history subtable)
│ ├── fields.inc # code pool form + usage subtable block
│ └── index.ctp # code pool listing
├── LICENSE # Apache 2.0
├── README.md # quickstart
├── doc.md # this document
└── VERSION
| File | Method | Contract |
|---|---|---|
Controller/AccessCodeEnrollerCoPetitionsController.php |
execute_plugin_petitionerAttributes($id, $onFinish) |
Called by the core CoPetitionsController::dispatch() at the wedge step. Throws on any failure; redirects to $onFinish on success. |
Controller/AccessCodesController.php |
$requires_co = true + calculateImpliedCoId() |
Ensures co_id is known for scoping the index / add flow. The actual stamping of co_id onto the new row happens via a hidden input in View/AccessCodes/fields.inc, mirroring Servers/fields.inc. |
Model/AccessCode.php |
normalize($raw, $stripWhitespace) |
Always uppercases. strip_whitespace is per-wedge; case-insensitivity is a plugin-wide invariant. |
Model/AccessCode.php |
findForLookup($coId, $enrollerId, $normalizedCode) |
Returns a single row, null (no match), or false (ambiguous, defensive against duplicate rows). |
Model/AccessCode.php |
consumeAtomically($id) |
Single conditional UPDATE; returns true iff exactly one row was affected. Never call this outside the petition hook. |
Model/AccessCode.php |
beforeValidate() |
Normalizes valid_from / valid_through to canonical Y-m-d H:i:s via strtotime() before validation runs, so validateTimestamp sees a well-formed string regardless of whether the admin typed "2026-04-10 13:45", "2026-04-10T13:45", "tomorrow 9am", etc. |
Model/AccessCode.php |
beforeSave() |
Auto-generates code if blank (reading transient code_length / code_charset / code_prefix form fields), normalizes the stored form, and unsets the transient fields before Cake tries to INSERT them. |
Model/AccessCodeEnroller.php |
cmPluginMenus() |
Returns the comain + coconfig sidebar entries for "Access Codes". Shape is menu-section keyed (not role-keyed) per retrieve_plugin_menus() in app/Lib/util.php. |
Model/AccessCodeUsage.php |
— (no methods) | Append-only audit row. Not Changelog-tracked so the plain UNIQUE (co_petition_id, access_code_id) index can serve as the dedup guard without partial-index gymnastics. |
Controller/AccessCodeEnrollerCoPetitionsController.php |
role-stamp block (inside the transaction) | Uses $this->CoPersonRole->saveField('valid_through', ...) not save([...]) — scoping the update to a single field bypasses Cake's required => true validation on sibling fields (co_person_id, status, affiliation) that would otherwise fire on partial updates. See §6a. |
Controller/AccessCodesController.php |
beforeFilter() |
Conditionally injects AccessCodeUsage into $view_contains only on the view action. StandardController reuses $view_contains as the paginate contain for the index action, so containing usages unconditionally would N+1-query usage rows on the index page. |
View/AccessCodes/fields.inc must print a hidden co_id input stamped from
$cur_co['Co']['id']. Without it, StandardController::add() has no way to
stamp the CO onto the row before validation runs. The fix mirrors the
pattern used by app/View/Servers/fields.inc.
- Verify
cmPluginMenus()returns a menu-section keyed array (not role-keyed). Sections used by the core UI:copeople,cogroups,comain,cmp,coconfig,canvas. This plugin registers undercomainandcoconfig. - Clear the Cake persistent cache so the new plugin menu is picked up:
rm -f app/tmp/cache/persistent/cake_core_object_map \ app/tmp/cache/persistent/cake_core_file_map
The validation rule must be validateTimestamp (lenient, strtotime()-based)
and the form field must be a plain Form->text() input with the datepicker-f
CSS class — not a Form->input(..., ['type' => 'datetime-local']). Cake 2.x's
datetime form helper auto-deconstructs the value into year/month/day/hour/minute
parts and breaks the round-trip. The canonical COmanage pattern is the same one
used in app/View/CoPersonRoles/fields.inc around valid_from / valid_through.
The wedge-list query in AccessCodesController::beforeRender() needs to scope
by CoEnrollmentFlow.co_id. Cake's Containable behavior fetches contained
models as separate SELECTs, so a contain-based condition on a grandchild
column cannot resolve. Use explicit joins (INNER JOIN
cm_co_enrollment_flow_wedges and cm_co_enrollment_flows) with
'recursive' => -1 instead.
Run the schema shell again and clear the model cache:
./Console/cake database
rm -f app/tmp/cache/models/cake_model_default_cm_access_code_enrollers \
app/tmp/cache/models/cake_model_default_cm_access_codesSymptom: POST to /access_code_enroller/access_codes/edit/<id> 500s. The
Cake log shows SQLSTATE[25P02]: In failed sql transaction in
AccessCodesController::beforeRender() at the CoGroup find — that's just
the secondary fallout. The real error is upstream; check the Postgres
log:
duplicate key value violates unique constraint "cm_access_codes_i_code"
Cause: the post-install partial unique index was never applied, so the
plain unique index from schema.xml is still in place. Changelog
behavior inserts an archive revision row during every edit that shares
(co_id, code) with the live row, and the plain index rejects it.
Fix: apply the partial index from Config/Schema/partial_indexes.sql:
docker exec <db-container> psql -U <user> -d <db> \
-f /srv/comanage-registry/local/Plugin/AccessCodeEnroller/Config/Schema/partial_indexes.sqlor run the DDL inline:
DROP INDEX IF EXISTS cm_access_codes_i_code;
CREATE UNIQUE INDEX cm_access_codes_i_code
ON cm_access_codes (co_id, code)
WHERE access_code_id IS NULL AND deleted IS NOT TRUE;Verify with \d cm_access_codes — the definition should end with
WHERE ((access_code_id IS NULL) AND (deleted IS NOT TRUE)).
Symptom: the petitioner fills in a valid code, hits Submit, and the page
lands on the petition error view with the generic er.internal message.
The Cake log shows something like:
AccessCodeEnroller: failed to stamp CoPersonRole.valid_through:
{"co_person_id":["A CO Person ID must be provided"],
"status":["A valid status must be selected"],
"affiliation":["content"]}
Primary cause: a required enrollment attribute is flagged "not modifiable".
A required r:affiliation / r:co_person_id / etc. attribute that is
flagged "not modifiable" in the enrollment flow is rendered read-only on
the form and its value is not submitted as part of the form data. Core
CoPetition::saveAttributes() then creates the CoPersonRole without a
valid affiliation / status / person link. When our wedge tries to stamp
valid_through on that half-populated role, Cake's required => true
validation on the sibling fields trips and the save fails.
Fix: edit the enrollment flow's attributes (CO Configuration →
Enrollment Flows → your flow → Enrollment Attributes), find any
required attribute that's flagged "Modifiable: no", and set it to
"Modifiable: yes". This also applies to the AccessCode attribute
itself — a non-modifiable AccessCode attribute isn't submitted either,
and the wedge fails with er.missing_code before even reaching the role
stamp.
Secondary hardening (already in place): the wedge hook uses
$this->CoPersonRole->saveField('valid_through', $validThrough) rather
than $this->CoPersonRole->save(['id' => X, 'valid_through' => Y]).
saveField() internally passes fieldList=[valid_through] to save(),
scoping validation to only the one field being updated and skipping the
sibling checks. This was added after encountering this bug — if you see
the error above, confirm the hook is using saveField() and not
save(). Without the fieldList scoping, Cake's required-field rules
fire on every partial update, not just the ones where a field is being
changed.
Role Valid Through / Role Valid Days form fails with "Set either Role Valid Through or Role Valid Days, not both"
Mutual exclusion between the two fields is enforced in
AccessCode::beforeValidate(). Either leave one field blank or clear
the other one. The two are deliberately exclusive — there's no
sensible semantic for "now + 30 days OR 2027-01-01, whichever."
CakePHP 2 caches the model's column list on first use. After editing
schema.xml:
./Console/cake database # apply DDL
rm -f app/tmp/cache/models/cake_model_default_cm_access_codes \
app/tmp/cache/models/cake_model_default_cm_access_code_enrollers \
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_mapWithout this, Cake will silently ignore new columns (they won't appear
in $this->data[...] on save) and new tables (Cake reports "missing
table" even though \d cm_foo shows it in psql). This is the most
common "did you clear the cache" symptom during plugin development.
- No bulk code creation — add codes one at a time through the UI, or
insert directly via SQL (remember to pre-normalize
codeto uppercase and strip whitespace, and setco_id/access_code_enroller_id). - Plaintext codes — by design. See §7.
- No rate limiting — by design. See §7.
- Role-validity stamping affects only the single
CoPersonRolethe petition creates, not theCoGroupMemberrow. Group memberships remain open-ended unless the admin time-bounds them separately. If you want auto-expiring memberships, set that window on the group policy or set it manually. See §6a for why this was chosen. - Flow-set
r:valid_from+ code-setrole_valid_throughcan produce aCoPersonRolerow wherevalid_from > valid_throughon disk, because the hook'ssaveField('valid_through', ...)call scopes validation to onlyvalid_through. Cake never cross-checks the existingvalid_fromon the row. Admins who mix these two are responsible for keeping them ordered. See §6a, "Edge cases".