Skip to content

Latest commit

 

History

History
235 lines (175 loc) · 13.4 KB

File metadata and controls

235 lines (175 loc) · 13.4 KB
name Cross-Cutting Data Patterns
description Soft-delete (Discardable), Pundit authorization patterns, Flipper feature flags, PaperTrail auditing, access control conventions
type project

Cross-Cutting Patterns

Patterns that span the entire codebase. Understanding these is prerequisite to working on any feature.

Critical gotchas (the "this will break if you get it wrong" items):

  • User.verified scope must use where(type: nil) — see §7 below
  • Controllers without index must use blanket skip_after_action — see §2 below
  • Relaxing directives use only:, restricting use except: — see §2 below
  • All shared props are visible in browser devtools — never include secrets — see §6 below

1. Soft-Delete (Discardable)

Concern: app/models/concerns/discardable.rb

Adds discarded_at timestamp column to models. Used by: User, Project, JournalEntry, Collaborator, CollaborationInvite, PendingCollaborationInvite, FeaturedProject, StreakGoal, HcbDonationRequest, ProjectGrantOrder, ProjectFundingTopup.

API:

  • record.discard — sets discarded_at = Time.current
  • record.undiscard — clears discarded_at
  • record.discarded? — check
  • Model.kept scope — WHERE discarded_at IS NULL
  • Model.discarded scope — WHERE discarded_at IS NOT NULL

Cascade behavior is explicit, not automatic:

  • Project#discard → transaction: soft-deletes collaborators, invites, journal entries (see arch-projects-journals.md)
  • JournalEntry#discard → transaction: restores any Lookout session tokens to user.pending_lookout_tokens, then destroys Recording links (hard-delete the join, preserving underlying media for reuse by future journal entries) via unclaim_recordings
  • User discard → soft-delete only (no cascade)
  • Trial user promotion → TrialUser.kept.where(email:).update_all(discarded_at: Time.current) — bulk soft-purge across all devices. This invalidates trial sessions on other devices, which is detected by redirect_discarded_trial_user! in the before-action chain (see auth-architecture.md)

Why soft-delete: data must be preservable and reversible per AGENTS.md. PII may need true deletion; other data needs soft-deletion for auditability. The developer decides each time.

2. Pundit Authorization

Default Configuration — app/controllers/application_controller.rb

after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

Every action must call authorize(resource) or policy_scope(collection). If neither is called, Pundit raises after the action completes (fail-closed).

Base Policy — app/policies/application_policy.rb

Default-deny: all actions return false. Subclasses override only what they permit.

Shared helpers:

  • admin?user&.admin?
  • staff?user&.staff?
  • owner?record.respond_to?(:user) && record.user == user
  • collaborators_enabled?user.present? && Flipper.enabled?(:collaborators, user) (also defined on the policy Scope class)

Rails 8.1 Callback Gotcha

ApplicationController defines after_action :verify_authorized, except: :index. Rails 8.1 validates that :index exists on the controller. Controllers without index get AbstractController::ActionNotFound.

Fix: Use blanket skip_after_action (no only: or except:):

skip_after_action :verify_authorized   # No authorizable resource
skip_after_action :verify_policy_scoped # No index action

Still call authorize/skip_authorization in each action explicitly.

only: vs except: Rule

The rule exists because a forgotten new action must default to MORE restriction, not less:

Directive type Use Rationale
Relaxing (skip_after_action :verify_authorized, allow_unauthenticated_access, allow_trial_access, skip_onboarding_redirect) only: Forgotten action keeps default restriction
Restricting (before_action :require_admin!) except: or blanket Forgotten action still gets the check

Never except: on relaxing. Never only: on restricting. Every access directive must have an inline comment explaining why.

Policy Summary

Non-exhaustive — app/policies/ has grown to cover review queues, shop/orders, currency ledgers (koi/gold), bulletin/campaigns, and the HCB grant flow. The core models are summarized below; consult the specific policy class for the rest.

Model Who can read Who can write Special
User Admin (all), self (own) Self (own), admin UserPolicy — admins see all users, regular users see/update only themselves
Project Admin, owner, collaborator (flagged), listed (public) Admin, owner Trial: max 1 project. manage_collaborators? requires verified + flag
JournalEntry Admin, owner, collaborator (flagged) Admin, owner, collaborator (flagged + verified) Author must own/collaborate on project
Ship Admin, staff reviewer Admin, reviewer, assigned reviewer Create: verified only. Frozen fields on submission
Collaborator Implied by parent policies Via invite flow No dedicated policy class — access managed through parent (Project/JournalEntry) policies
CollaborationInvite Admin, inviter, invitee Inviter (create/revoke), invitee (accept/decline) Flag-gated, must be pending for accept/decline/revoke
Critter Owner only Owner only
MailMessage Visible per filter scope Admin only Has user-specific read/dismiss tracking via MailInteraction
Recording Implied by journal entry Any authenticated user (create) RecordingPolicy — create gated by journal entry authorization
OnboardingResponse Self Self (create/update) OnboardingResponsePolicy — any user can create, owner can update
LapseTimelapse Admin, owner Create: any authenticated user
LookoutTimelapse Admin, owner Any authenticated user
YouTubeVideo YouTubeVideoPolicy — custom lookup? action, any authenticated user

3. Flipper Feature Flags

Adapter: ActiveRecord (stores in flipper_features and flipper_gates tables) Config: config/initializers/flipper.rb User integration: include Flipper::Identifier in User model Admin UI: /flipper (admin-only route constraint)

Active flags:

Flag Controls Checked in
:collaborators Project/journal collaboration features Policies, controllers, shared props
:shop Shop/redemption features Controllers, shared props
:hcb_top_ups HCB project funding top-ups Controllers, shared props
:disable_ticket_claims / :ticket_claims_override Global kill switch for summit ticket claiming + per-user exemption (mirrors the submission/reship gate pattern); checked via User#ticket_claims_disabled? TicketClaimsController, ShopItemsController

Usage pattern:

# In policies
def collaborators_enabled?
  user.present? && Flipper.enabled?(:collaborators, user)
end

# In controllers (shared to frontend) — only shared for full (non-trial) users
inertia_share features: -> {
  next {} unless current_user && !current_user.trial?
  {
    collaborators: Flipper.enabled?(:collaborators, current_user),
    shop: Flipper.enabled?(:shop, current_user),
    grant_fulfillment: true, # not flag-gated; always on for full users
    hcb_top_ups: Flipper.enabled?(:hcb_top_ups, current_user)
  }
}

4. PaperTrail Auditing

Models with has_paper_trail: User, Project, JournalEntry, Ship, Collaborator, CollaborationInvite, PendingCollaborationInvite, MailMessage, StreakDay, StreakGoal, ProjectFlag, ReviewerNote, plus the Reviewable concern (mixed into the review models) and the HCB models (HcbConnection, HcbDonationRequest, HcbTransaction, HcbGrantCard, HcbGrantSetting, ProjectGrantOrder, ProjectFundingTopup).

Stores all changes in versions table. Particularly important for Ships (review workflow transparency — frozen fields + status changes + reviewer assignment all tracked) and MailMessages (notification audit trail).

5. Authentication Before-Action Chain

Defined in app/controllers/concerns/authentication.rb, included in ApplicationController. Order is critical — each step depends on previous steps having run.

set_current_user          → Load user from session[:user_id] (STI-aware)
authenticate_user!        → Redirect unauthenticated to root
redirect_banned_user!     → Redirect banned to /sorry
redirect_discarded_trial_user!  → Clear stale trial sessions (must precede verified check)
authenticate_verified_user!     → Block trial users (default-deny)
redirect_to_onboarding!         → Force onboarding completion

Skip methods (class-level, always use only:):

  • allow_unauthenticated_access only: %i[...] — skips authenticate_user!
  • allow_trial_access only: %i[...] — skips authenticate_verified_user!
  • skip_onboarding_redirect only: %i[...] — skips redirect_to_onboarding!

Common pitfall: allow_unauthenticated_access does NOT skip authenticate_verified_user!. Public endpoints that signed-in trial users visit need BOTH allow_unauthenticated_access AND allow_trial_access.

6. Inertia Shared Props & Security

ApplicationController shares auth state, flash, feature flags, and paths with every page via inertia_share.

All shared props are visible in browser devtools. Never include:

  • hca_token, lapse_token, slack_token, device_token (server-only encrypted fields)
  • Internal IDs that could enable enumeration
  • Any data the user shouldn't see

6.5. PII Classification

AGENTS.md groups PII broadly and reserves it for admins. In practice the sensitivity tiers are:

  • Admin-only PII: email, hcb_email, pronouns, bio, first_name/last_name, country, is_adult, HCA identity payload, device_token. Gate on current_user.admin? before serializing.
  • Not sensitive in this codebase: slack_id. Every platform user is also a member of the Hack Club Slack workspace, so Slack IDs are effectively community-public. Safe to expose to staff/reviewers and to other authenticated users where useful (e.g. review pages, admin user lists). Still don't expose to fully unauthenticated routes or across trial-user boundaries without a reason.
  • Server-only, never client: hca_token, lapse_token, slack_token, device_token (encrypted at rest; never in Inertia props).

The AGENTS.md line "PII (email, slack_id, etc.) must only be exposed to admins" is over-broad — slack_id is the explicit exception. When reviewing code or writing serializers, treat the list above as authoritative.

7. User Types (STI)

Full User (User) Trial User (TrialUser)
type column nil 'TrialUser'
Auth HCA OAuth Email + device cookie
Scope Cross-device Device-scoped
trial? / verified? false / true true / false
admin? / staff? Possible (via roles) Always false
Can earn critters Yes No
Can collaborate Yes (if flag enabled) No
Can create ships Yes No
Project limit Unlimited 1

Verified scope: User.verified = where(type: nil). See auth-architecture.md for the PostgreSQL NULL gotcha.

8. Encryption

Rails Active Record encryption enabled for sensitive fields:

  • user.hca_token — HCA OAuth access token
  • user.lapse_token — Lapse OAuth token
  • user.slack_token — Slack OAuth token
  • user.device_token — deterministic encryption (for find_by lookups)
  • ship.frozen_hca_data — user identity snapshot at submission
  • shop_order.phone, shop_order.legacy_address, shop_order.structured_address — shipping PII. structured_address is an HCA-shaped hash (first_name/last_name/line_1/line_2/city/state/postal_code/country) serialized to JSON then encrypted; new orders write it. legacy_address holds the pre-refactor formatted-blob string for orders not yet backfilled. ShopOrder#address returns a display string, preferring structured_address and falling back to legacy_address.
  • hcb_connection.access_token, hcb_connection.refresh_token — HCB OAuth tokens

Session cookie: standard Rails cookie encryption (_fallout_session, 3-month expiry). Trial device token: cookies.encrypted[:trial_device_token] (httponly, secure, strict, 1-year expiry).

9. Route Constraints

Admin routes protected at routing layer (defense in depth on top of Pundit). Constraint classes live in lib/constraints/ under the Constraints namespace: AdminConstraint (user.admin?), StaffConstraint (user.staff?, i.e. admin || reviewer), and ReviewerConstraint (user.reviewer?).

constraints Constraints::StaffConstraint.new do   # user.staff?
  namespace :admin do
    # dashboards, reviewer pages, review queues (reviews/*), project_flags,
    # projects (index/show), users (index/show), ships (path: "reviews"),
    # bulletin_events, featured_projects (staff-readable; mutations admin-gated in controllers)
  end
end

constraints Constraints::AdminConstraint.new do   # user.admin?
  mount MissionControl::Jobs::Engine, at: "/jobs"
  mount Flipper::UI.app(Flipper), at: "/flipper"
  mount RailsPerformance::Engine, at: "/admin/performance" if ENV["REDIS_URL"].present?
  namespace :admin { ... } # admin-only mutations: project/user overrides, grant orders, etc.
end

Constraints check request.session[:user_id]User.find_by → role check. Trial users (roles: []) always fail.