Skip to content

Adds Accounts with credentials to DB, models, and routes#5

Open
soumyaray wants to merge 2 commits intomainfrom
3-user-accounts
Open

Adds Accounts with credentials to DB, models, and routes#5
soumyaray wants to merge 2 commits intomainfrom
3-user-accounts

Conversation

@soumyaray
Copy link
Copy Markdown
Contributor

@soumyaray soumyaray commented Apr 23, 2026

Summary

Introduces Accounts as a first-class resource: secure password storage via SCrypt key-stretching, encrypted PII with HMAC-keyed lookup, a role-based enrollment model, and service objects. Enrollment-management routes ship ungated — policy gating follows in 7-policies.


Password storage

app/lib/key_stretch.rb · app/models/password.rb

Passwords are hashed with SCrypt (opslimit=2**20, memlimit=2**24, digest_size=64) via RbNaCl — not SHA or bcrypt. SCrypt's sequential-memory requirement means GPU/ASIC parallelism doesn't give an attacker the same speed advantage it does against SHA-family hashes. Per-account salts defeat rainbow tables; identical passwords produce different digests across accounts.

Password (storage/comparison) and KeyStretch (the SCrypt primitive) are separate so KeyStretch can be reused for symmetric key derivation in later branches.

The plaintext password is never persisted, never logged, and never serialized. Account#to_json returns only {type, id, username, email}. The integration spec asserts that password_digest, salt, email_secure, and email_hash are absent from every API response.

Email as PII — dual-column pattern

db/migrations/001_accounts_create.rb · app/models/account.rb

Storing plaintext email in a university system violates FERPA/GDPR. But an encrypted column can't be searched — every ciphertext is different, so WHERE email = ? can't work. Solution: two columns with two independent keys.

  • email_secure — RbNaCl SimpleBox (non-deterministic ciphertext). Confidentiality at rest.
  • email_hash — HMAC-SHA256 keyed with a separate HASH_KEY. Deterministic. Used for WHERE email_hash = ? lookup.

DB_KEY and HASH_KEY are separate environment variables. Compromising one doesn't compromise the other: leaking DB_KEY exposes stored content but not lookup capability; leaking HASH_KEY enables lookup enumeration but not decryption. The email_hash column carries the unique constraint — the ciphertext column cannot, since the same plaintext encrypts to a different value each time.

See design-notes/encrypted-email-lookup.md for the full analysis.

Account, not User

app/models/account.rb

The model captures the facets relevant to this system — username, email, password, avatar — not the whole human. "Account" is a system artifact; one person may hold multiple accounts and one account may hold multiple roles. "User" conflates the two and tends to attract unrelated concerns.

Role scoping — two join tables, one reference table

db/migrations/005_roles_create.rb · 006_account_roles_create.rb · 007_enrollments_create.rb

A single roles reference table feeds two join tables with different scopes:

  • System roles (account_roles) — platform-wide: admin, creator, member
  • Course roles (enrollments) — per-course: owner, instructor, staff, student

Scope is encoded structurally (which join table the row lives in), not by duplicating the reference table. The same account can be a platform member and a course owner simultaneously.

Enrollments unify ownership and membership

app/models/enrollment.rb · app/models/course.rb

A single enrollments(account_id, course_id, role_id) table replaces the common split of owner_id on the parent + a separate collaborators join. Two structures for one relationship can disagree; one table cannot. Course#owner is a query over enrollments, not a column.

The unique constraint on [account_id, course_id, role_id] allows an account to hold multiple roles in the same course (e.g., owner + instructor) without duplicating rows. Duplicate triples are rejected at the DB level — no application-level guard needed.

Service objects and atomic ownership

app/services/create_course_for_owner.rb

CreateCourseForOwner wraps course creation and owner-enrollment creation in a single Sequel transaction. If either insert fails, both roll back. A course with no owner is a silent authorization hole once policy enforcement lands — the transaction prevents it at the data layer regardless of how the route is called.

EnrollAccountInCourse is the single seam for all non-owner enrollments. Seeds use it now; enrollment routes call it; 7-policies will add authorization checks inside it without touching the routes.

Mass-assignment defense

app/models/account.rb · app/models/enrollment.rb

whitelist_security on Account allows only :username, :email, :password, :avatar. Sending created_at, email_hash, or password_digest in the request body returns 400. Enrollment's whitelist allows only :account_id, :course_id, :role_id — defense-in-depth against any future code path that mass-assigns enrollment rows.

Cascade semantics

app/models/account.rb · app/models/course.rb

Three different cascade choices, each matched to its association shape:

  • events: :destroy / locations: :destroy on Course — per-row .destroy; preserves future callback hooks
  • enrollments: :delete on Course — one bulk DELETE; Enrollment has no hooks, so per-row overhead is unnecessary
  • courses: :nullify on Account (via many_to_many) — removes join-table rows in one bulk DELETE; courses survive but become ownerless

Note: :nullify on a many_to_many removes the bridge rows, not the associated records. On a one_to_many it would set the FK to NULL (which would fail the NOT NULL constraint on enrollments.account_id). Same keyword, different behavior depending on association type.

DB constraints as the enforcement layer

Structural invariants live in the schema:

  • accounts.username — unique
  • accounts.email_hash — unique
  • account_roles — composite primary key
  • enrollments.[account_id, course_id, role_id] — unique
  • All FK columns — NOT NULL

Application code relies on these without defensive double-checks. If Role.first(name: 'owner') returns nil, the NOT NULL on enrollments.role_id raises and rolls back the CreateCourseForOwner transaction. The DB is the authority on structural invariants; application code enforces contextual rules.

Ungated routes

Enrollment-management routes (GET/POST/DELETE /api/v1/courses/:id/enrollments) are intentionally ungated, consistent with the courses/events/locations routes at this stage. Any authenticated-or-not client can create or remove enrollments. 7-policies wraps these routes with role-based enforcement — the data model is here; the gates follow.


Spec coverage

  • spec/unit/passwords_spec.rb — digest hides raw password; correct/incorrect match; Mandarin UTF-8 round-trip (regression for SecureDB.decrypt force_encoding fix from 2-db-hardening)
  • spec/unit/secure_db_spec.rb — deterministic HMAC output; different inputs produce different hashes
  • spec/integration/api_accounts_spec.rb — create; show; mass-assignment blocked (400); sensitive fields absent from response
  • spec/integration/api_enrollments_spec.rb — list/create/delete happy paths; unknown username → 404; unknown role → 400; duplicate triple → 409; cross-course enrollment_id → 404
  • spec/integration/service_create_course_for_owner_spec.rb — happy path (course + enrollment exist, course.owner == account); SAD (unknown owner raises and no Course row survives — proves transaction rollback)

bundle exec rake release_check (spec + rubocop + bundle-audit): green.

Plan for the 3-user-accounts branch: introduce Account model with secure
credentials, role join tables, and enrollments.

This branch adds:

- accounts table with encrypted PII (email_secure, keyed-hash email_hash
  via HMAC-SHA256, password_digest via SCrypt)
- roles reference table seeded with all 7 role names, joined to accounts
  via account_roles (system-level) and to courses via enrollments
  (per-course, with role_id)
- KeyStretch lib + extended SecureDB (HASH_KEY env var, .hash() method,
  newkey:hash Rake task)
- Three service objects: CreateCourseForOwner (transactional — course +
  owner enrollment atomic), CreateEventForCourse, CreateLocationForCourse
- Account routes: POST /api/v1/accounts, GET /api/v1/accounts/[username]
- Specs: passwords_spec (with Mandarin round-trip in commit 2),
  api_accounts_spec, service_create_course_for_owner_spec (commit 3)

Credence reference: a3c6099..4ea1f2f (3 commits, mirrored 1:1).

Key design decisions:

- No owner_id FK on courses; owner is the account whose enrollment for the
  course has role 'owner'. Enrollments pulled forward from 7-policies per
  master plan amendment (Decisions #10).
- DB constraints (unique [account_id, course_id, role_id], NOT NULL FKs,
  unique username/email_hash) are the structural-invariant enforcement
  layer; specs do not test framework guarantees.
- CreateCourseForOwner wraps both inserts in a Sequel transaction; the
  spec asserts rollback on unknown owner.
- Schema renumbered (courses 001 -> 002, locations 002 -> 003, events 003
  -> 004) and three new migrations added; allowed pre-5-deployable per
  master plan Decisions #10 (schema evolution).
Introduces Accounts as a first-class persisted resource with SCrypt-hashed
passwords (via KeyStretch) and HMAC-keyed email lookup (email_secure +
email_hash on a separate HASH_KEY). Enrollments land this branch too, pulled
forward from 7-policies per the project's pre-deployable schema-evolution
policy so the lecture's join-table example maps onto real on-disk tables.

Schema (renumbered pre-5-deployable; renames via git mv):
- 001_accounts_create.rb (new)
- 002_courses_create.rb (was 001; no owner_id — ownership lives in enrollments)
- 003_locations_create.rb (was 002)
- 004_events_create.rb (was 003)
- 005_roles_create.rb (new; 7 role names seeded)
- 006_account_roles_create.rb (new; create_join_table system-level roles)
- 007_enrollments_create.rb (new; unique [account_id, course_id, role_id])

Models: Account (email encrypt + HMAC, SCrypt password), Password (value
object over KeyStretch), Role, Enrollment, plus Course convenience scopes
(owner, instructors, staff, students).

Services: CreateCourseForOwner (transactional course + owner enrollment),
EnrollAccountInCourse (single seam for non-owner enrollments — seeds use it
this branch, management routes arrive in 7-policies), CreateEventForCourse,
CreateLocationForCourse.

Library: SecureDB.hash() (HMAC-SHA256) + NoHashKeyError; KeyStretch mixin
(SCrypt opslimit=2**20, memlimit=2**24, digest_size=64).

Config: HASH_KEY env var, rake newkey:hash, extended db:load_models +
db:reset_seeds + db:seed + top-level reseed tasks (per PPTX slide 22).

Routes: POST api/v1/accounts, GET api/v1/accounts/[username]. Event and
location POST handlers refactored to call the new services.

Seeds: 9 accounts (3 staff + 6 students), 4 canonical courses, 13 enrollment
triples covering owner/staff/student roles; system roles assigned in-seeder.

Destroy cascades: Course.association_dependencies enrollments: :delete (1-query
bulk DELETE, no per-row hooks); Account.association_dependencies courses:
:nullify on the many_to_many (removes the enrollment bridges in one bulk DELETE
while leaving courses standing). The role-specific scopes (owner/instructors/
staff/students) carry all role-aware access — no role-blind #members.

Specs ship with the feature, not as a follow-up:
- passwords_spec — three tests (digest hides raw; correct password matches;
  wrong password doesn't) with Mandarin-containing password from day one.
  The force_encoding fix already shipped in 2-db-hardening, so Credence's
  separate UTF-8-fix commit is deliberately skipped here.
- api_accounts_spec — HAPPY GET/POST + BAD mass-assignment; asserts no
  salt/password/password_hash/email_secure/email_hash leaks through the API.
- service_create_course_for_owner_spec — HAPPY (course + owner enrollment
  both exist after .call; course.owner round-trips to the passed account)
  and SAD (UnknownOwnerError for unknown owner_id; no Course row left behind,
  proving the transaction wraps both inserts atomically).

This collapses Credence's commit-1/commit-2 split (feature then service spec)
into a single commit. Tests and code are one change, not two.

Docs: docs/schema.md — Mermaid ER diagram of the 7-table schema plus prose
notes on decisions a pure ERD can't express (email_secure/email_hash split,
SCrypt, role-name enumeration, cascade behavior, why courses has no owner_id).
README gets a "For Contributors" section pointing to it.

Security: plaintext passwords never persisted/logged/serialized;
GPU/ASIC-resistant via SCrypt; per-account salt; email ciphertext
non-deterministic; HMAC lookup column with separate HASH_KEY; mass-assignment
whitelist on Account allows only :username/:email/:password/:avatar.
@soumyaray soumyaray changed the title 3 user accounts Adds Accounts with credentials to DB, models, and routes Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant