Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages