Adds Accounts with credentials to DB, models, and routes#5
Open
Adds Accounts with credentials to DB, models, and routes#5
Conversation
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.
562ee5e to
c4e960b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.rbPasswords 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) andKeyStretch(the SCrypt primitive) are separate soKeyStretchcan be reused for symmetric key derivation in later branches.The plaintext password is never persisted, never logged, and never serialized.
Account#to_jsonreturns only{type, id, username, email}. The integration spec asserts thatpassword_digest,salt,email_secure, andemail_hashare absent from every API response.Email as PII — dual-column pattern
db/migrations/001_accounts_create.rb·app/models/account.rbStoring 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 separateHASH_KEY. Deterministic. Used forWHERE email_hash = ?lookup.DB_KEYandHASH_KEYare separate environment variables. Compromising one doesn't compromise the other: leakingDB_KEYexposes stored content but not lookup capability; leakingHASH_KEYenables lookup enumeration but not decryption. Theemail_hashcolumn carries theuniqueconstraint — the ciphertext column cannot, since the same plaintext encrypts to a different value each time.See
design-notes/encrypted-email-lookup.mdfor the full analysis.Account, not User
app/models/account.rbThe 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.rbA single
rolesreference table feeds two join tables with different scopes:account_roles) — platform-wide:admin,creator,memberenrollments) — per-course:owner,instructor,staff,studentScope is encoded structurally (which join table the row lives in), not by duplicating the reference table. The same account can be a platform
memberand a courseownersimultaneously.Enrollments unify ownership and membership
app/models/enrollment.rb·app/models/course.rbA single
enrollments(account_id, course_id, role_id)table replaces the common split ofowner_idon the parent + a separate collaborators join. Two structures for one relationship can disagree; one table cannot.Course#owneris 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.rbCreateCourseForOwnerwraps 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.EnrollAccountInCourseis the single seam for all non-owner enrollments. Seeds use it now; enrollment routes call it;7-policieswill add authorization checks inside it without touching the routes.Mass-assignment defense
app/models/account.rb·app/models/enrollment.rbwhitelist_securityonAccountallows only:username, :email, :password, :avatar. Sendingcreated_at,email_hash, orpassword_digestin 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.rbThree different cascade choices, each matched to its association shape:
events: :destroy/locations: :destroyonCourse— per-row.destroy; preserves future callback hooksenrollments: :deleteonCourse— one bulkDELETE;Enrollmenthas no hooks, so per-row overhead is unnecessarycourses: :nullifyonAccount(viamany_to_many) — removes join-table rows in one bulk DELETE; courses survive but become ownerlessNote:
:nullifyon amany_to_manyremoves the bridge rows, not the associated records. On aone_to_manyit would set the FK to NULL (which would fail the NOT NULL constraint onenrollments.account_id). Same keyword, different behavior depending on association type.DB constraints as the enforcement layer
Structural invariants live in the schema:
accounts.username— uniqueaccounts.email_hash— uniqueaccount_roles— composite primary keyenrollments.[account_id, course_id, role_id]— uniqueApplication code relies on these without defensive double-checks. If
Role.first(name: 'owner')returns nil, the NOT NULL onenrollments.role_idraises and rolls back theCreateCourseForOwnertransaction. 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-policieswraps 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 forSecureDB.decryptforce_encodingfix from2-db-hardening)spec/unit/secure_db_spec.rb— deterministic HMAC output; different inputs produce different hashesspec/integration/api_accounts_spec.rb— create; show; mass-assignment blocked (400); sensitive fields absent from responsespec/integration/api_enrollments_spec.rb— list/create/delete happy paths; unknown username → 404; unknown role → 400; duplicate triple → 409; cross-course enrollment_id → 404spec/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.