Skip to content

Latest commit

 

History

History
246 lines (190 loc) · 13.4 KB

File metadata and controls

246 lines (190 loc) · 13.4 KB

AccessCodeEnroller

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.

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


Features

  • 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_count increment.
  • 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 new CoPersonRole.valid_through (absolute role_valid_through or relative role_valid_days) that wins over any r:valid_through the flow sets.
  • Concurrency-safe. consumeAtomically() is a single conditional UPDATE, so max_uses is 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).

Install

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


Configure

1. Create the enrollment flow

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)

2. Add the wedge

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.

3. Create some codes

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.


What the petitioner sees

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 its valid_through stamped. 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.


File layout

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

Concurrency and max_uses

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.


Security posture

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 / ambiguous messages so attackers can't distinguish failure modes.
  • Plaintext codes are never logged. $this->log() records the candidate row id where 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 cmadmin or coadmin.

Known limitations

  • 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-normalize code, set co_id and access_code_enroller_id).
  • Role-validity stamping only affects the single CoPersonRole the petition creates. It does not touch the CoGroupMember row — group memberships remain open-ended unless the admin bounds them separately. The code expires the role, not the group.

License

Apache License, Version 2.0. See LICENSE.