Skip to content

Latest commit

 

History

History
740 lines (619 loc) · 39 KB

File metadata and controls

740 lines (619 loc) · 39 KB

AccessCodeEnroller — Reference Documentation

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.


1. Concepts

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_id and to exactly one wedge config via access_code_enroller_id.
  • use_count is only ever updated via consumeAtomically() — a conditional UPDATE that is the single source of truth for max_uses enforcement.

2. Runtime architecture

 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.


3. Database schema

Tables are created by the COmanage schema shell from Config/Schema/schema.xml with the cm_ prefix added automatically.

3.1 cm_access_code_enrollers — wedge config

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.

3.2 cm_access_codes — the code pool

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_codepartial 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 uses Changelog behavior — see the boxed note below.
  • access_codes_i1 on access_code_enroller_id
  • access_codes_i2 on co_group_id
  • access_codes_i3 on status
  • access_codes_i4 on access_code_id (speeds up Changelog archive lookups)

Why the partial unique index

Changelog behavior turns every edit into: insert an archive copy of the old row (same (co_id, code) but with access_code_id pointing 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 filter access_code_id IS NULL AND deleted IS NOT TRUE excludes archive rows and soft-deleted rows from the uniqueness scope, so only the live row is constrained — which is what we actually want. CakePHP's schema.xml can'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 from Config/Schema/partial_indexes.sql.

3.3 cm_access_code_usages — redemption audit log

One row per successful consume. Append-only: never updated, never edited, not Changelog-tracked. Serves two purposes:

  1. Admin audit trail — the history table rendered on the code view page.
  2. 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 calling consumeAtomically() to avoid burning a wasted use_count increment on the common double-click, and also re-checks it inside the catch block 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_i1 on access_code_id
  • access_code_usages_i2 on co_petition_id
  • access_code_usages_i3 on co_person_id
  • access_code_usages_i_petcodeUNIQUE (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.


4. Configuration

4.1 Create the enrollment flow

  1. CO Configuration → Enrollment Flows → Add
  2. 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 CoPetitionAttribute for that label and fails with er.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 of petitionerAttributes may be missing required fields, which then trips our role-stamp save() with a cascade of "required field" validation errors. Leave required attributes modifiable.

4.2 Add and configure the wedge

  1. In the same enrollment flow, add an Enrollment Flow Wedge of type AccessCodeEnroller at step petitionerAttributes.

  2. 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 (AccessCode by 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.

4.3 Create codes

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.


5. Validation order and error messages

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.


6. Concurrency model

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 WHERE clause repeats every check so the DB is authoritative under concurrent submissions.
  • If lastAffected() != 1, the caller treats the race as lost and throws er.code_exhausted. This is consistent: the user's code either made it past the gate or did not.
  • The entire execute_plugin_petitionerAttributes hook runs in one begin / commit / rollback so that a failure to create the CoGroupMember row or write the history record undoes the use_count increment.

Guarantees under concurrency:

  • Two petitioners consuming the same code with max_uses = K and K slots remaining → at most K group memberships created and use_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.


6a. Per-code CoPersonRole validity stamping

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 on now() at redemption, not on valid_from or created.

Mutual exclusion is enforced in AccessCode::beforeValidate(); the admin form rejects a row with both fields set.

Why this is independent of the code's own validity window

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).

Order of operations at the petitionerAttributes step

 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.

Override semantics vs. flow-set r:valid_through

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.

Why saveField(), not save()

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.

valid_from is deliberately NOT touched

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.

Edge cases

  • 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_id is 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_through to yesterday produces a role with valid_through in the past. Core's CoPersonRole::beforeSave() auto-transitions the status to Expired. No special handling needed.
  • r:valid_from in the future + code role_valid_through earlier than that. The resulting row would violate CoPersonRole's valid_from <= valid_through invariant on disk, but because we submit only valid_through via saveField(), the cross-field precedes/follows rule never fires and the write goes through. This is a documented misconfiguration trap — admins who mix flow-set r:valid_from with code-set role_valid_through take responsibility for keeping them ordered.
  • Role save failure. If the partial update fails (validation trip on valid_through itself, or a DB error), a RuntimeException is thrown. The outer transaction rolls back, so use_count is not incremented and no AccessCodeUsage, CoGroupMember, or history record is written. Failure here is transparent to the rest of the audit trail.

7. Security posture

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 / ambiguous so attackers can't distinguish failure modes.
  • No plaintext logging. $this->log() records the candidate row id where 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 cmadmin or coadmin.

8. Admin operations (recipes)

Retire a code without losing audit history

Edit the code, set Status = Suspended. Subsequent attempts fail with disabled_code. The row and its use_count history are preserved.

Hand out codes for a time-boxed event

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.

Rotate a compromised code

  1. Suspend the original code (don't delete — you want the use_count and last_used for the audit trail).
  2. Create a replacement code pointing at the same group.
  3. Distribute the new code out-of-band.

Bulk-generate many codes

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_codes via SQL, making sure to pre-normalize (UPPER(code), whitespace-stripped) and to set access_code_enroller_id and co_id correctly.

Allow one person per code

Set max_uses = 1. The first successful consume exhausts the code.

Let the same person enroll twice

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.


9. File layout

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

Key hooks and contracts

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.

10. Troubleshooting

"This field cannot be left blank" on co_id when adding a code

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.

Sidebar menu doesn't show "Access Codes"

  • 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 under comain and coconfig.
  • 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

Validation rejects a correctly-typed Valid From / Valid Through

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.

Access Codes index page 500s with missing FROM-clause entry for table "CoEnrollmentFlow"

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.

"Missing table cm_access_code_enrollers" after schema edits

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_codes

"An Internal Error Has Occurred" when editing any access code

Symptom: 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.sql

or 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)).

Submission fails with "An unexpected error occurred" — CoPersonRole validation trips

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."

New plugin tables or new columns on an existing table don't show up

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_map

Without 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.


11. Known limitations (v1)

  • No bulk code creation — add codes one at a time through the UI, or insert directly via SQL (remember to pre-normalize code to uppercase and strip whitespace, and set co_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 CoPersonRole the petition creates, not the CoGroupMember row. 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-set role_valid_through can produce a CoPersonRole row where valid_from > valid_through on disk, because the hook's saveField('valid_through', ...) call scopes validation to only valid_through. Cake never cross-checks the existing valid_from on the row. Admins who mix these two are responsible for keeping them ordered. See §6a, "Edge cases".