Skip to content

Authentication & Authorization Audit + Architecture PRD

Product: Objectuve (codename: Enkidu) Feature: Authn/Authz separation, conventions, helpers, and per-violation fixes Status: Shipped — April 2026 Date: April 2026 Type: Security + Infrastructure PRD


✓ SHIPPED — Delivered April 2026. All acceptance criteria met and live in production.

TL;DR

The Enkidu API and frontend do not consistently differentiate authentication ("who are you?") from authorization ("are you allowed?"). The same generic error messages are returned for both failure modes; the frontend's Apollo error handler treats both as console.log noise. The result is a class of bugs where unauthenticated users get past auth checks (the four AI mutations have no auth at all), authorized users get bounced to sign-in instead of seeing "you don't have access," and admins can't tell from the response whether they need to re-authenticate or whether they're on a screen they shouldn't be on.

This document is two things in one file:

  • Part 1: Audit. Concrete findings across the Rails GraphQL layer, the Vue/Apollo frontend, and the Clerk integration boundary. Every finding has a file path, line number, severity, current code, and recommended fix.
  • Part 2: PRD. A pragmatic fix architecture: keep the existing Clerk + interaction shape, add a small set of conventional helpers + extension codes, and remediate every violation. No Pundit-scale rewrite. Backwards-compatible incremental change.

The audit found 38 distinct violations across both the API and the frontend, including 9 Critical issues — most notably four AI mutations with no authentication check at all, and a pattern of "bang!" helpers that return error objects instead of raising them, which has masked auth bugs from code review for months.


Definitions used in this document

  • Authentication (authn) = verifying identity. "Who is this request from?" Failure means the user is not signed in or their session is invalid. The right response is HTTP 401-equivalent / GraphQL UNAUTHORIZED. The right UX is "sign in to continue."
  • Authorization (authz) = verifying permission. "Is this signed-in user allowed to do this?" Failure means the user IS signed in but lacks the role, ownership, or scope to perform this operation. The right response is HTTP 403-equivalent / GraphQL FORBIDDEN. The right UX is "you don't have access" — staying on the current page, NOT redirecting to sign-in.
  • Resource ownership = a sub-case of authz. "User X is signed in. Is this resource theirs to read/edit?" Failure is FORBIDDEN, not NOT_FOUND. (Returning NOT_FOUND for unowned resources leaks existence and makes debugging harder.)

Treating authn and authz failures the same way is a bug. A non-admin user who visits /admin and gets redirected to /sign-in will re-authenticate and immediately get redirected back — they think auth is broken when really they just don't have permission. This is exactly the symptom that produced the (now-deleted) bugfix/unauthorized-production branch.


Part 1: Audit

1.1 What's already in place

Backend helpers (rails_api/app/graphql/types/query_type.rb lines 622-641):

ruby
def current_user
  context[:current_user]
end

def admin?
  current_user&.admin
end

def authenticate_user!
  return if current_user.present?
  GraphQL::ExecutionError.new('UNAUTHORIZED')
end

def authorize_admin!
  return if admin?
  GraphQL::ExecutionError.new('FORBIDDEN')
end

Clerk JWT verification (rails_api/app/services/clerk_jwt_verifier.rb): RS256 verification via JWKS, 1-hour cache, swallows all errors and returns nil. Handles JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIssuerError and generic StandardError with the same behavior — return nil.

Controller (rails_api/app/controllers/graphql_controller.rb): reads the SessionToken header (PascalCase), supports three modes (Clerk JWT, Fallback <secret>:<email>, MagicLink <token>), populates context[:current_user] for resolvers. Always returns HTTP 200 — auth failures live in the GraphQL response body.

Frontend Apollo client (ionic_frontend/src/apollo-client.ts): authLink injects SessionToken header from Clerk, errorLink catches network 401s and GraphQL errors (Sentry-only logging), errorHandler in apolloProvider has a switch statement on error.gqlError.message that recognizes UNAUTHORIZED / FORBIDDEN / NOT FOUND — but every branch only calls console.error.

Frontend router (ionic_frontend/src/router/index.ts): beforeEach guard checks meta.public, attempts Clerk token fetch, redirects unsigned users to /sign-in?redirect=<dest>. No role check — every authenticated user can navigate to every protected route, including /admin.

1.2 The five anti-patterns this audit found

The 38 individual violations cluster into five recurring patterns. Fixing these patterns wholesale is the architecture proposed in Part 2.

Anti-pattern 1: "Bang!" helpers that return instead of raising

authenticate_user! and authorize_admin! end in ! (Ruby convention for "may raise") but they return the error object instead of raising it. Callers must use the awkward pattern:

ruby
return authenticate_user! if authenticate_user!  # ← reads as nonsense

This pattern works (because the helper returns truthy on failure and nil on success) but it has hidden costs:

  • New code that copies an existing pattern often forgets the return, silently leaking past the auth check
  • Code review can't easily spot a missing check — authenticate_user! on its own line is a no-op
  • Refactors that move the call into a different code path break in subtle ways
  • The helper pretends to be a guard but is actually a value-returning method

Found in: every query field that uses these helpers (~30 call sites).

Anti-pattern 2: Auth checks delegated to the interaction layer with no enforcement

Many mutations look like this:

ruby
def resolve(input:)
  result = ::SomeInteraction.call(
    user: context[:current_user],
    **input
  )
  # ... handle result
end

The mutation passes current_user to the interaction. The interaction might check it. The mutation has no idea. If the interaction has a soft check (return false if current_user.blank?) or no check, the mutation succeeds anonymously without anyone noticing.

Found in: at least 15 mutations. Worst case is add_goal_event.rb which has no auth check at all and toggle_goal_event_encouragement.rb which explicitly allows anonymous encouragements via return false if current_user.blank? (silent fail, no error returned).

Anti-pattern 3: Generic error strings without GraphQL extension codes

Interactions that DO check auth often return errors like:

ruby
result.fail(error: 'You can not update this user')

This becomes a GraphQL response like:

json
{ "errors": ["You can not update this user"] }

The frontend sees a string, not a code. To handle 401 vs 403 differently, the frontend would have to string-match — which is what apollo-client.ts line 114 does:

typescript
switch (error.gqlError.message) {
  case 'UNAUTHORIZED': console.error('401 - UNAUTHORIZED')
  case 'FORBIDDEN': console.error('403 - FORBIDDEN')
  // ...
}

This switch only matches the exact strings 'UNAUTHORIZED' and 'FORBIDDEN'. Every interaction that returns a more descriptive message ('You can not update this user', 'Unauthorized', 'Authentication required') falls through to default and is silently logged.

Found in: at least 8 interactions. Worst case is update_user.rb, add_goal.rb, update_goal.rb, and create_community_post.rb — each returns a custom string for what should be a FORBIDDEN extension code.

Anti-pattern 4: Public queries that should require auth

Several query fields in query_type.rb have NO auth check at all and return personalized or sensitive data:

  • ally_activity_feed — returns a user's social graph activity
  • community_insights — returns personalized community engagement stats
  • recommended_communities — returns personalized recommendations
  • user_allies — returns a user's social connections
  • authz_check — leaks whether an arbitrary user has admin status

The four AI mutations are even worse: generate_milestones, get_advice, refine_description, get_insight accept arbitrary input from an unauthenticated request and forward it to an LLM. Anyone can hit the GraphQL endpoint and burn the platform's LLM budget. (This becomes more urgent post-Meet Coach and post-LiteLLM extraction — see meet-coach-prd.md and dedicated-ai-service-prd.md.)

Anti-pattern 5: Frontend treats authn and authz failures identically

Three layers all conflate the two error types:

  1. apollo-client.ts errorLink (lines 67-91): catches network 401s, calls console.error('[Apollo] Session expired'), captures to Sentry, does nothing else. No redirect, no cache reset, no token refresh.
  2. apollo-client.ts errorHandler (lines 111-131): switch statement that calls console.error on each branch. No redirect, no toast, no UX surface for 403.
  3. Router guard (router/index.ts lines 213-237): only checks "is there a Clerk token?" Never checks role. A non-admin user navigating to /admin passes the guard, loads Admin.vue, and the view renders an "Access Denied" panel — but the URL stays at /admin, so subsequent navigation still lands them in the same dead end.

The combined effect: a user whose session expires sees their dashboard slowly fill with empty states and silent failures. A non-admin who somehow gets a link to /admin lands on the access-denied screen and stays there. Neither flow is recoverable without a manual reload or sign-out.

1.3 Findings — Backend (Rails)

Critical (9)

#FileLineCurrent codeRecommended fixCategory
B-C-1app/graphql/mutations/ai/get_advice.rb11-19def resolve(user_context:) proceeds without checking current_userAdd raise GraphQL::ExecutionError.new('UNAUTHORIZED', extensions: { code: 'UNAUTHORIZED' }) unless context[:current_user] as first line of resolvemissing_authn
B-C-2app/graphql/mutations/ai/generate_milestones.rb12-21Same — no auth checkSame fix as B-C-1missing_authn
B-C-3app/graphql/mutations/ai/refine_description.rb15-23Same — no auth checkSame fix as B-C-1missing_authn
B-C-4app/graphql/mutations/ai/get_insight.rb15-35Same — no auth checkSame fix as B-C-1missing_authn
B-C-5app/graphql/types/query_type.rb222-226authz_check query has no auth check; can be queried anonymously to leak any user's admin statusAdd authn check; require self-or-admin to read another user's authz statemissing_authn
B-C-6app/graphql/types/query_type.rb432-466ally_activity_feed has no auth check; exposes private ally relationships and activityAdd authn check; require user_id == current_user.public_id || current_user.adminmissing_authn
B-C-7app/graphql/types/query_type.rb468-483community_insights has no auth check; leaks per-user community engagement dataSame as B-C-6missing_authn
B-C-8app/graphql/types/query_type.rb551-567user_allies has no auth check; returns a user's social connectionsSame as B-C-6missing_authn
B-C-9app/interactions/social/toggle_goal_event_encouragement.rb8-28, 38Treats current_user.blank? as a valid anonymous encouragement (return false if current_user.blank?) — silent fall-through; no error raisedReplace with explicit result.fail(error: 'UNAUTHORIZED') and add extension codemissing_authn

High (12)

#FileLineCurrent codeRecommended fixCategory
B-H-1app/graphql/types/query_type.rb630-634authenticate_user! returns a GraphQL::ExecutionError instead of raisingConvert to raise GraphQL::ExecutionError.new('UNAUTHORIZED', extensions: { code: 'UNAUTHORIZED' }). Update all ~30 call sites in the same migration.bang_pattern
B-H-2app/graphql/types/query_type.rb636-640authorize_admin! same problemSame fix as B-H-1 with 'FORBIDDEN' codebang_pattern
B-H-3app/graphql/mutations/report_content.rb13-29No explicit auth check in mutation, no visible check in interaction; anonymous reports possibleAdd raise UNAUTHORIZED unless current_user at top of resolvemissing_authn
B-H-4app/graphql/mutations/add_goal_event.rb21-42Passes current_user to interaction with no presence check; interaction has none eitherAdd raise UNAUTHORIZED unless current_usermissing_authn
B-H-5app/graphql/mutations/toggle_feedback_vote.rb13-26No visible auth check; interaction may allow anonymous votesAdd explicit checkmissing_authn
B-H-6app/graphql/mutations/review_content_report.rb11-23No explicit admin check in mutationAdd raise FORBIDDEN unless current_user&.adminmissing_authz
B-H-7app/graphql/mutations/delete_notification.rb13No visible auth checkAdd raise UNAUTHORIZED unless current_usermissing_authn
B-H-8app/graphql/mutations/check_in_habit.rb14-31Passes current_user to interaction without presence checkAdd explicit checkmissing_authn
B-H-9app/graphql/mutations/use_streak_freeze.rb14-31SameSamemissing_authn
B-H-10app/graphql/mutations/create_community.rb19-37SameSamemissing_authn
B-H-11app/graphql/types/query_type.rb382-394goal_progress_data has authn check but no resource ownership verificationAdd raise FORBIDDEN unless goal.user_id == current_user.id || current_user.adminno_ownership_check
B-H-12app/graphql/types/query_type.rb546-549recommended_communities has no auth checkAdd authn checkmissing_authn

Medium-High wrong-code violations (8)

These return generic strings; frontend can't differentiate authn from authz:

#FileLineCurrent codeRecommended fix
B-W-1app/interactions/user_identity/update_user.rb72-79result.fail(error: 'You can not update this user')result.fail(error: 'FORBIDDEN', code: 'FORBIDDEN')
B-W-2app/interactions/goal_tracking/add_goal.rb113-115result.fail(error: 'You can not add a goal for this user')Same
B-W-3app/interactions/goal_tracking/update_goal.rb109-111result.fail(error: 'You can not add a goal for this user')Same
B-W-4app/interactions/social/create_community_post.rb14result.fail(error: 'Unauthorized') (generic, even when user IS authenticated)result.fail(error: 'FORBIDDEN', code: 'FORBIDDEN')
B-W-5app/interactions/content_moderation/review_content_flag.rb8unless current_user&.admin then result.fail(error: 'Unauthorized')Should be 'FORBIDDEN' since user IS authenticated
B-W-6app/interactions/social/join_community.rb11, 61Generic error stringSame as B-W-1
B-W-7Most other failing interactionsvariousMix of 'Unauthorized', 'You can not...', 'Access denied' stringsStandardize on 'UNAUTHORIZED' and 'FORBIDDEN' with extension codes
B-W-8app/interactions/feedback/create_feedback_post.rb8'Authentication required' (correct concept, wrong wire format)Standardize on 'UNAUTHORIZED'

Medium (mass-assignment risk) (7)

These mutations accept user_id as input parameter, allowing one user to act as another if the interaction-layer auth check is missing or breaks:

#FileLineIssue
B-M-1app/graphql/mutations/update_user.rb7, 21Accepts user_id; relies entirely on interaction check
B-M-2app/graphql/mutations/add_goal.rb8Accepts user_id as required argument
B-M-3app/graphql/mutations/update_goal.rb9Same
B-M-4app/graphql/mutations/join_community.rb7Same
B-M-5app/graphql/mutations/create_community_post.rb6Same
B-M-6app/graphql/mutations/update_user_photo.rb7Optional user_id; defaults to current_user if omitted
B-M-7app/graphql/mutations/store_device_token.rb7Optional user_id

Pattern fix: remove user_id from input arguments wherever the action is on the current user; always derive from context[:current_user]. Where admins legitimately act on other users, keep user_id but enforce admin check at the mutation layer, not just the interaction.

Lower-priority backend issues (2)

#FileLineSeverityIssueFix
B-L-1app/services/clerk_jwt_verifier.rb16-21LOW-MEDNo distinction between expired / invalid / network failure — all return nil. JWKS fetch failures have no fallback to stale cache during Clerk outages.Add stale-cache fallback in fetch_jwks rescue branch. Distinguish error types in logs for observability.
B-L-2app/controllers/graphql_controller.rb72-84LOW-MEDFirst-sign-in race: two concurrent requests can both hit the auto-create path. Currently no ActiveRecord::RecordNotUnique rescue.Wrap ClerkUserSync.call in rescue → on duplicate, refetch user.

1.4 Findings — Frontend (Vue / Apollo / Router)

Critical (3)

#FileLineSeverityCurrent behaviorRecommended fix
F-C-1src/apollo-client.ts111-131CRITICALerrorHandler switch on error.gqlError.message only calls console.error for each case. Zero UX surface for 401 or 403.Replace with: parse extensions.code; on UNAUTHORIZED → clear cache, sign user out via Clerk, redirect to /sign-in?redirect=<current>; on FORBIDDEN → show toast "You don't have access to that" and stay on page.
F-C-2src/apollo-client.ts67-91CRITICALerrorLink detects network 401 but only logs. Stale auth state remains in cache.On 401: apolloClient.cache.reset(), Session.logout(), redirect to /sign-in.
F-C-3src/router/index.ts213-237CRITICALRouter guard never checks role. Non-admin users navigate to /admin, load the view, and get the access-denied panel — URL stays at /admin.Add meta.requiredRole to protected routes; in guard, after sync, check currentUser.admin against required role; if mismatch, redirect to /dashboard?denied=admin (NOT /sign-in).

High (6)

#FileLineSeverityIssueFix
F-H-1src/apollo-client.ts114-122HIGHError detection by message string match (case 'UNAUTHORIZED'); fragile, breaks if backend returns 'Authentication required' or 'You can not edit this user'Read error.extensions?.code first; fall back to message only as a deprecation shim
F-H-2src/apollo-client.ts27-55HIGHauthLink swallows Clerk session errors silently (try/catch with empty body); request proceeds unauthenticatedLog when token fetch fails; if Clerk reports user is signed out, trigger logout flow
F-H-3src/views/Admin.vue270-271HIGHuseQuery(ADMIN_STATS_QUERY) runs unconditionally; non-admin users trigger 403 errors in the backgroundGuard with enabled: computed(() => !!isAdmin.value)
F-H-4src/helpers/session.js50-66HIGHlogout() is defined but never called from Apollo error handlersWire logout() into the new errorLink path on 401
F-H-5src/constants/graphql/*.jsallHIGHNo standard error-handling pattern across queries/mutationsAdd a useApiError() composable; require all useQuery/useMutation calls to wire it
F-H-6src/views/SignIn.vue226-250HIGHStale Clerk session detection runs only on the sign-in page; doesn't run when a user reloads a protected route mid-sessionMove to app initialization (App.vue or main.ts) so it runs on every load

Medium (5)

#FileLineIssueFix
F-M-1src/views/Goal.vue496-511Conflates 404 (not found) with 403 (not yours); both redirect to public goal viewDifferentiate by extension code; only redirect on NOT_FOUND
F-M-2src/views/Community.vue397-399Catch-all error handler masks auth failures behind "Could not join" toastDifferentiate by code
F-M-3src/router/index.ts220-224Fallback token in localStorage is checked but never validated; can be expired/invalidValidate on first call; clear and redirect on failure
F-M-4src/sentry.ts26-34setSentryUser() called after sync but never on logout or 401Add Sentry.setUser(null) to logout flow
F-M-5src/composables/useClerkSync.ts16-24setSynced() doesn't validate that user is authorized (vs just authenticated)If user role is revoked, clear synced state and redirect

1.5 Findings — Clerk integration boundary (4)

#FileLineSeverityIssueFix
C-1src/apollo-client.ts42-48MEDIUMMulti-tab sign-out: Tab B holds a stale Clerk session after sign-out in Tab A. clerk.session?.getToken() still returns a valid token until next refresh.Use localStorage storage event listener to detect cross-tab logout; clear cache on event
C-2app/services/clerk_jwt_verifier.rb45-58MEDIUMJWKS fetch has no fallback to stale cache if Clerk API is unreachableAdd stale-cache fallback in rescue branch
C-3app/controllers/graphql_controller.rb89-99LOW (dev only)Fallback auth not rate-limited. With FALLBACK_AUTH_SECRET enabled, an attacker can enumerate emails via timing on User.find_by.Add Rack::Attack rule for Fallback prefix; document that fallback should NEVER be enabled in production
C-4app/controllers/graphql_controller.rb52LOWHeader lookup is case-sensitive (request.headers['SessionToken']); a client sending session-token would be silently treated as unauthenticatedRails normalizes headers but it's worth a defensive log if a non-standard variant is observed

1.6 Audit summary

  • Backend violations: 38 total (9 Critical, 12 High, 8 wrong-code, 7 mass-assignment, 2 Lower)
  • Frontend violations: 14 total (3 Critical, 6 High, 5 Medium)
  • Clerk boundary: 4 (0 Critical, 2 Medium, 2 Low)

The Critical-tier findings are concentrated in two places:

  1. AI mutations have no authentication. Anyone can hit the GraphQL endpoint and burn LLM budget.
  2. Frontend treats authn and authz identically. Both are silently logged. There is no UX surface for 403 anywhere in the codebase.

Everything else is consequential but lower-blast-radius. The fix architecture in Part 2 addresses the patterns, not just the individual symptoms.


Part 2: PRD — Fix Architecture

2.1 Goals

  1. Backend has bulletproof helpers that raise (not return) and that produce consistent GraphQL extension codes.
  2. Frontend has a single error-handling surface that distinguishes 401 from 403 and routes UX accordingly.
  3. Every existing violation gets a concrete fix with file path, line number, and recommended code.
  4. Backwards-compatible incremental change — no Pundit-scale rewrite, no Interaction gem replacement, no auth library change.
  5. Conventions are documented and enforceable so new mutations can't regress (lint rule + RSpec helpers).

2.2 Non-goals

  • Replacing Clerk
  • Replacing the custom Interaction gem
  • Introducing Pundit, CanCanCan, or any other policy library
  • Migrating to a row-level security model
  • Adding RBAC (that's the standalone admin app PRD's job)

2.3 Backend architecture changes

2.3.1 New conventions (documented in docs/architecture/authentication.md after this PRD ships)

Convention 1: All resolvers use require_auth! and require_admin! helpers that RAISE.

The existing helpers stay (renamed for clarity) and gain a sibling that actually raises:

ruby
# rails_api/app/graphql/concerns/authorization.rb
module Authorization
  extend ActiveSupport::Concern

  # Raises GraphQL::ExecutionError with extension code on failure.
  # Use these in resolvers / mutations.
  def require_auth!
    return if current_user.present?
    raise GraphQL::ExecutionError.new(
      'You must be signed in to do this.',
      extensions: { code: 'UNAUTHORIZED' }
    )
  end

  def require_admin!
    require_auth!  # an unauthenticated request to an admin route is UNAUTHORIZED first
    return if current_user.admin?
    raise GraphQL::ExecutionError.new(
      "You don't have access to this.",
      extensions: { code: 'FORBIDDEN' }
    )
  end

  def require_owner!(resource)
    require_auth!
    return if resource.respond_to?(:user_id) && resource.user_id == current_user.id
    return if current_user.admin?
    raise GraphQL::ExecutionError.new(
      "You don't have access to this.",
      extensions: { code: 'FORBIDDEN' }
    )
  end

  def current_user
    context[:current_user]
  end
end

The concern is included in BaseQuery, BaseMutation, and BaseField. Old helpers (authenticate_user!, authorize_admin!) remain as deprecated wrappers that delegate to the new versions, with a Rails.logger.warn if called from non-test code. Removed in a follow-up release once all call sites are migrated.

Convention 2: All mutations check auth at the resolver layer, not in the interaction.

The interaction layer is business logic. Auth is HTTP boundary concern. Today they're mixed, which is why so many mutations rely on the interaction to validate. The new pattern:

ruby
class CreateGoal < BaseMutation
  argument :name, String, required: true

  def resolve(name:)
    require_auth!  # ← top of every resolve method
    result = ::Goals::CreateGoal.call(user: current_user, name: name)
    # ...
  end
end

The interaction continues to receive user: but trusts that the caller has validated. Defense in depth: interactions still raise if user: is nil (catches programmer error) but they use a generic exception (ArgumentError) not a GraphQL one — they're not API-aware.

Convention 3: Authz failures use the standardized GraphQL extension code, not message strings.

Every authz-related GraphQL::ExecutionError includes extensions: { code: 'UNAUTHORIZED' | 'FORBIDDEN' }. Frontend reads error.extensions.code, never error.message. Messages become user-facing strings; codes are the contract.

Convention 4: Mutations never accept user_id for actions on the current user.

If a mutation operates on the signed-in user, it derives from context[:current_user]. If a mutation must accept a user ID (e.g., admin acting on someone else), it requires admin role at the resolver layer.

Convention 5: Resource ownership is enforced at the resolver, not the interaction.

Use require_owner!(resource) after require_auth!. The interaction may also defensively check, but the resolver is the authoritative gate.

2.3.2 GraphQL extension code catalog

All authn/authz errors use exactly one of these codes (no others, no synonyms):

CodeMeaningWhen to use
UNAUTHORIZEDNo valid identity. The user is not signed in (or session expired).Missing current_user, expired JWT, missing token
FORBIDDENIdentity is valid but lacks permission.Non-admin hitting admin field, non-owner editing resource, role check failure
NOT_FOUNDResource doesn't exist and it's safe to disclose that.Public resource not in DB

NOT_FOUND is reserved for genuinely-not-existing public data. Returning NOT_FOUND for a private resource the current user doesn't own is forbidden — that's an authz decision and must use FORBIDDEN. (Otherwise, NOT_FOUND becomes an existence-disclosure side-channel.)

2.3.3 Per-violation backend remediation (linked to audit table)

Each Critical and High finding from §1.3 becomes a one-or-two-line fix. The pattern:

For missing authn (B-C-1 through B-C-9):

ruby
# Before
def resolve(user_context:)
  service = ::Ai::CoachService.new
  # ...

# After
def resolve(user_context:)
  require_auth!
  service = ::Ai::CoachService.new
  # ...

For wrong code (B-W-1 through B-W-8):

ruby
# Before
result.fail(error: 'You can not update this user')

# After
result.fail(error: 'FORBIDDEN', code: 'FORBIDDEN')
# (and the resolver translates result.details to GraphQL::ExecutionError with the code)

For missing ownership (B-H-11):

ruby
# Before
def goal_progress_data(goal_id:)
  ::GoalAnalytics::FetchProgress.call(goal_id: goal_id, user: current_user)
end

# After
def goal_progress_data(goal_id:)
  require_auth!
  goal = Goal.find(goal_id)
  require_owner!(goal)
  ::GoalAnalytics::FetchProgress.call(goal: goal)
end

For mass assignment (B-M-1 through B-M-7):

ruby
# Before
argument :user_id, ID, required: true
def resolve(user_id:, **input)
  # interaction validates current_user matches user_id
end

# After
def resolve(**input)
  require_auth!
  # always uses current_user; user_id is no longer accepted
end

2.3.4 Backend test requirements

Add an RSpec shared example: 'requires authentication' and 'requires admin'. Every mutation spec must include one of:

ruby
it_behaves_like 'requires authentication'
it_behaves_like 'requires admin'
it_behaves_like 'requires resource ownership', :goal

Add a CI check that fails the build if any new GraphQL mutation file is added without one of these shared examples in its spec. Implementation: a simple Ruby script that grep-checks the spec/graphql/mutations directory.

2.4 Frontend architecture changes

2.4.1 Single source of truth for error handling

Replace the current scattered error handling with one composable:

typescript
// src/composables/useApiError.ts
import { useRouter } from 'vue-router'
import { useToast } from '@/composables/useToast'
import { Session } from '@/helpers/session'
import { apolloClient } from '@/apollo-client'

export function useApiError() {
  const router = useRouter()
  const { showToast } = useToast()

  function handle(error: ApolloError | Error) {
    const code = extractCode(error)

    switch (code) {
      case 'UNAUTHORIZED':
        // Identity failure: session expired or never authenticated.
        // Sign out, clear cache, redirect to sign-in with return URL.
        Session.logout(apolloClient)
        router.push({
          name: 'SignIn',
          query: { redirect: router.currentRoute.value.fullPath, reason: 'expired' }
        })
        return

      case 'FORBIDDEN':
        // Permission failure: user is signed in but not allowed.
        // Stay on page. Show toast. Surface the access-denied state.
        showToast({
          title: "You don't have access",
          message: "This page or action requires permissions you don't have.",
          variant: 'warning'
        })
        return

      case 'NOT_FOUND':
        showToast({ title: 'Not found', message: "We couldn't find what you were looking for." })
        return

      default:
        // Network or generic error
        showToast({ title: 'Something went wrong', message: 'Please try again.' })
        Sentry.captureException(error)
    }
  }

  return { handle }
}

function extractCode(error: ApolloError | Error): string | null {
  if ('graphQLErrors' in error && error.graphQLErrors.length > 0) {
    return error.graphQLErrors[0].extensions?.code as string ?? null
  }
  if ('networkError' in error && error.networkError) {
    const status = (error.networkError as any).statusCode
    if (status === 401) return 'UNAUTHORIZED'
    if (status === 403) return 'FORBIDDEN'
    if (status === 404) return 'NOT_FOUND'
  }
  return null
}

The Apollo errorLink calls into this composable's logic via a global handler. Every useQuery/useMutation call site that catches errors uses const { handle } = useApiError().

2.4.2 Router guard with role checks

Add meta.requiredRole to protected routes:

typescript
// src/router/index.ts
const routes = [
  { path: '/admin', component: Admin, meta: { requiredRole: 'admin' } },
  { path: '/dashboard', component: Dashboard, meta: { requiresAuth: true } },
  // ...
]

router.beforeEach(async (to, from, next) => {
  if (to.meta.public) return next()

  const token = await getClerkToken()
  if (!token) {
    return next({ name: 'SignIn', query: { redirect: to.fullPath } })
  }

  await syncClerkUser()

  // Role check happens AFTER authn — different failure mode
  if (to.meta.requiredRole === 'admin') {
    const currentUser = await getCurrentUser()
    if (!currentUser?.admin) {
      // FORBIDDEN, not UNAUTHORIZED. Don't bounce to sign-in.
      return next({ name: 'Dashboard', query: { denied: 'admin' } })
    }
  }

  next()
})

The Dashboard.vue reads route.query.denied and shows an inline banner: "You don't have access to the admin area." This gives the user clear feedback without dropping them on a dead-end URL.

2.4.3 Token lifecycle

Add automatic token-refresh on UNAUTHORIZED:

typescript
// In errorLink (apollo-client.ts)
const errorLink = onError(({ networkError, graphQLErrors, operation, forward }) => {
  const isAuthError = (graphQLErrors?.[0]?.extensions?.code === 'UNAUTHORIZED') ||
                      ((networkError as any)?.statusCode === 401)

  if (isAuthError) {
    // Try one refresh before giving up
    return new Observable((observer) => {
      window.Clerk?.session?.getToken({ skipCache: true })
        .then((newToken) => {
          if (newToken) {
            // Replace header and retry the operation
            operation.setContext(({ headers = {} }) => ({
              headers: { ...headers, SessionToken: newToken }
            }))
            forward(operation).subscribe(observer)
          } else {
            // Refresh failed — sign out
            Session.logout(apolloClient)
            router.push({ name: 'SignIn', query: { reason: 'expired' } })
            observer.complete()
          }
        })
        .catch(() => {
          Session.logout(apolloClient)
          router.push({ name: 'SignIn', query: { reason: 'expired' } })
          observer.complete()
        })
    })
  }

  // Other errors fall through
  return forward(operation)
})

This is the standard Apollo retry-with-refreshed-token pattern. Critically, it only retries once — an UNAUTHORIZED on the retry means the session is genuinely dead and the user is redirected to sign-in.

2.4.4 Multi-tab sign-out detection

In App.vue:

typescript
onMounted(() => {
  window.addEventListener('storage', (event) => {
    if (event.key === 'objectuve_user_public_id' && event.newValue === null) {
      // Another tab signed out
      Session.logout(apolloClient)
      router.push({ name: 'SignIn' })
    }
  })
})

2.4.5 Per-violation frontend remediation

Each Critical and High frontend finding (F-C-1 through F-H-6) maps to one of the architectural changes above. The fixes happen in three files:

  • src/apollo-client.ts — F-C-1, F-C-2, F-H-1, F-H-2 (errorLink + errorHandler rewrite, token refresh)
  • src/router/index.ts — F-C-3, F-M-3 (role-based guard, fallback validation)
  • src/composables/useApiError.ts — new file, replaces ad-hoc error handling
  • src/views/Admin.vue — F-H-3 (enabled: !!isAdmin)
  • src/helpers/session.js — F-H-4 (wire logout() into error path)
  • src/views/SignIn.vue — F-H-6 (move stale-session check to App.vue)
  • src/views/Goal.vue, src/views/Community.vue — F-M-1, F-M-2 (use useApiError)
  • src/sentry.ts — F-M-4 (Sentry.setUser(null) on logout)
  • src/composables/useClerkSync.ts — F-M-5 (validate role on sync)
  • src/App.vue — multi-tab listener

2.5 Migration plan

Phase 1 — Stop the bleeding (1 sprint, ~3 days)

  1. Add the four AI mutation auth checks (B-C-1 through B-C-4)
  2. Add auth checks to ally_activity_feed, community_insights, recommended_communities, user_allies, authz_check (B-C-5 through B-C-8)
  3. Fix toggle_goal_event_encouragement silent fallthrough (B-C-9)

These nine fixes can ship in a single PR. Each is a one-line change. They eliminate every Critical-tier backend violation. They are not dependent on the new helpers — they can use the existing pattern temporarily.

Phase 2 — Backend conventions (1 sprint)

  1. Add Authorization concern with require_auth!, require_admin!, require_owner!
  2. Update BaseQuery, BaseMutation, BaseField to include the concern
  3. Migrate every existing call site from old helpers to new ones (~30 sites in query_type.rb, ~15 mutations)
  4. Mark old helpers as deprecated with logged warnings
  5. Add RSpec shared examples ('requires authentication', 'requires admin', 'requires resource ownership')
  6. Add CI check that fails on missing auth shared examples in mutation specs
  7. Standardize all interaction error returns on 'UNAUTHORIZED' / 'FORBIDDEN' with codes (B-W-1 through B-W-8)

Phase 3 — Frontend conventions (1 sprint)

  1. Create useApiError composable
  2. Rewrite apollo-client.ts errorLink with token-refresh-then-redirect logic
  3. Add role-based router guards (F-C-3)
  4. Wire up multi-tab sign-out listener
  5. Update Admin.vue, Goal.vue, Community.vue to use useApiError
  6. Update Apollo provider error handler to delegate to useApiError
  7. Fix Sentry.setUser(null) on logout

Phase 4 — Mass-assignment hardening (1 sprint)

  1. Audit all user_id arguments in mutations (B-M-1 through B-M-7)
  2. Remove user_id argument from mutations that operate on the current user
  3. For mutations that legitimately accept other users (admin operations), enforce admin check at the mutation layer
  4. Update GraphQL schema, regenerate frontend types, fix any frontend callers

Phase 5 — Polish and edge cases (1 sprint)

  1. Clerk JWKS stale-cache fallback (C-2)
  2. First-sign-in race condition rescue (B-L-2)
  3. Fallback auth rate limiting (C-3)
  4. Stale Clerk session detection moved to App.vue (F-H-6)
  5. Remove deprecated authenticate_user! / authorize_admin! helpers

2.6 What this PRD does NOT change

  • The Clerk integration (still RS256, still JWKS-cached, still SessionToken header)
  • The custom Interaction gem (still Interaction::Base, still delegate_input, still result.details)
  • The single-admin-boolean model on User (replaced by RBAC in the standalone admin app PRD)
  • The per-resolver authz pattern (no policy library)
  • The GraphQL endpoint structure (still single POST /graphql)
  • Apollo cache architecture (still InMemoryCache with current type policies)

2.7 Backwards compatibility

Every change in this PRD is additive or syntactically equivalent. The only breaking change is removing user_id from mutation inputs in Phase 4 — and that's gated on a frontend update in the same sprint to stop sending the field. No external API consumers exist (Objectuve has no public GraphQL API), so the only "consumers" are the Vue frontend and the (future) admin dashboard, both of which are in this monorepo and updated atomically.

2.8 Open questions

  1. Should require_admin! raise UNAUTHORIZED for unauthenticated requests, or FORBIDDEN? Recommendation: UNAUTHORIZED first, then FORBIDDEN. An unauthenticated request to an admin endpoint should be told to sign in first; only after that can we determine they're not allowed.

  2. Should mutations log auth failures to Sentry? Recommendation: yes for FORBIDDEN (suggests UI bug or attempted abuse), no for UNAUTHORIZED (extremely common, mostly noise). Configurable per-mutation.

  3. How to handle the existing bugfix/unauthorized-production symptom? The deleted-but-historically-relevant branch suggests there have been production bugs in this exact area. Recommendation: cite specific Sentry events when this PRD is implemented to verify each fix lands cleanly.

  4. Should we fail closed on missing auth helpers? Add a CI check that flags any new GraphQL field/mutation that doesn't include require_auth!, require_admin!, or an explicit # public comment. Forces an opt-out, not opt-in.

  5. Resource ownership for nested resources? A Milestone belongs to a Goal belongs to a User. Does require_owner!(milestone) walk up the chain? Recommendation: yes — the helper checks user_id on the resource and falls back to goal.user_id and similar. Defined per-model in a small Ownable concern.

  6. Should the standalone admin app PRD's RBAC roles slot into require_admin!? Yes — once the admin_roles table exists, require_admin! becomes require_role!(:admin) with a list of accepted roles. This PRD is the prerequisite that makes that change one-line.

2.9 Critical files (full list)

Backend — created

  • rails_api/app/graphql/concerns/authorization.rb — new concern with require_auth!, require_admin!, require_owner!
  • rails_api/spec/support/auth_shared_examples.rb — RSpec shared examples
  • bin/check_auth_specs — CI script that fails build if mutation specs miss auth examples

Backend — modified (Phase 2 + Phase 4)

  • rails_api/app/graphql/types/base_query.rb — include Authorization
  • rails_api/app/graphql/types/base_mutation.rb — include Authorization
  • rails_api/app/graphql/types/base_field.rb — include Authorization
  • rails_api/app/graphql/types/query_type.rb — migrate ~30 call sites, add missing checks (lines 222-226, 432-466, 468-483, 546-549, 551-567, 382-394)
  • rails_api/app/graphql/mutations/ai/get_advice.rb — B-C-1
  • rails_api/app/graphql/mutations/ai/generate_milestones.rb — B-C-2
  • rails_api/app/graphql/mutations/ai/refine_description.rb — B-C-3
  • rails_api/app/graphql/mutations/ai/get_insight.rb — B-C-4
  • rails_api/app/graphql/mutations/report_content.rb — B-H-3
  • rails_api/app/graphql/mutations/add_goal_event.rb — B-H-4
  • rails_api/app/graphql/mutations/toggle_feedback_vote.rb — B-H-5
  • rails_api/app/graphql/mutations/review_content_report.rb — B-H-6
  • rails_api/app/graphql/mutations/delete_notification.rb — B-H-7
  • rails_api/app/graphql/mutations/check_in_habit.rb — B-H-8
  • rails_api/app/graphql/mutations/use_streak_freeze.rb — B-H-9
  • rails_api/app/graphql/mutations/create_community.rb — B-H-10
  • rails_api/app/graphql/mutations/update_user.rb — B-M-1 (remove user_id)
  • rails_api/app/graphql/mutations/add_goal.rb — B-M-2
  • rails_api/app/graphql/mutations/update_goal.rb — B-M-3
  • rails_api/app/graphql/mutations/join_community.rb — B-M-4
  • rails_api/app/graphql/mutations/create_community_post.rb — B-M-5
  • rails_api/app/graphql/mutations/update_user_photo.rb — B-M-6
  • rails_api/app/graphql/mutations/store_device_token.rb — B-M-7
  • rails_api/app/interactions/social/toggle_goal_event_encouragement.rb — B-C-9
  • rails_api/app/interactions/user_identity/update_user.rb — B-W-1
  • rails_api/app/interactions/goal_tracking/add_goal.rb — B-W-2
  • rails_api/app/interactions/goal_tracking/update_goal.rb — B-W-3
  • rails_api/app/interactions/social/create_community_post.rb — B-W-4
  • rails_api/app/interactions/content_moderation/review_content_flag.rb — B-W-5
  • rails_api/app/interactions/social/join_community.rb — B-W-6
  • rails_api/app/interactions/feedback/create_feedback_post.rb — B-W-8
  • rails_api/app/services/clerk_jwt_verifier.rb — C-2 (stale-cache fallback)
  • rails_api/app/controllers/graphql_controller.rb — B-L-2 (race rescue), C-3 (rate limit), C-4 (header tolerance)

Frontend — created

  • ionic_frontend/src/composables/useApiError.ts — new composable, single source of truth
  • ionic_frontend/src/composables/useToast.ts — if not already present, for FORBIDDEN UX

Frontend — modified

  • ionic_frontend/src/apollo-client.ts — F-C-1, F-C-2, F-H-1, F-H-2 (rewrite errorLink and errorHandler, add token refresh)
  • ionic_frontend/src/router/index.ts — F-C-3, F-M-3 (role-based guard, fallback validation)
  • ionic_frontend/src/views/Admin.vue — F-H-3 (enabled: !!isAdmin)
  • ionic_frontend/src/helpers/session.js — F-H-4 (wire logout into error path)
  • ionic_frontend/src/views/SignIn.vue — F-H-6 (move stale check to App.vue)
  • ionic_frontend/src/App.vue — multi-tab listener, stale session check
  • ionic_frontend/src/views/Goal.vue — F-M-1
  • ionic_frontend/src/views/Community.vue — F-M-2
  • ionic_frontend/src/sentry.ts — F-M-4
  • ionic_frontend/src/composables/useClerkSync.ts — F-M-5

Documentation

  • docs/architecture/authentication.md — update to document the new conventions, the four GraphQL extension codes, the helper API, and the frontend useApiError pattern

2.10 Verification

After implementation:

  1. Spec coverage — every mutation in rails_api/spec/graphql/mutations/ has at least one it_behaves_like 'requires authentication' or 'requires admin'. CI script bin/check_auth_specs enforces this.
  2. Manual test 1: AI mutation without authcurl the GraphQL endpoint with no SessionToken, send a getAdvice mutation, expect { "errors": [{ "message": "...", "extensions": { "code": "UNAUTHORIZED" } }] }. Today this returns { "data": { "advice": "..." } }.
  3. Manual test 2: Non-admin hitting /admin — sign in as a non-admin user, navigate to /admin. Expect to land on /dashboard?denied=admin with an inline "You don't have access to the admin area" banner. Today the user lands on /admin with an Access Denied panel and an unrecoverable URL.
  4. Manual test 3: Token expiration — sign in, then in DevTools clear the Clerk session cookie, then trigger a query. Expect: token refresh attempt, then sign-out + redirect to /sign-in?reason=expired. Today: silent failure, empty state in UI.
  5. Manual test 4: Multi-tab logout — sign in in two tabs, sign out in tab A, navigate in tab B. Expect tab B to detect the logout and redirect to sign-in.
  6. Manual test 5: Resource ownership — sign in as user A, attempt to query/mutate a goal owned by user B. Expect FORBIDDEN. Today: depends on which mutation; some return generic strings, some succeed silently.
  • docs/product/standalone-admin-app-prd.md — depends on this PRD's RBAC-friendly helper shape
  • docs/product/dedicated-ai-service-prd.md — auth on AI mutations is a prerequisite for cost tracking; LiteLLM virtual keys assume per-user attribution
  • docs/product/meet-coach-prd.md — persona preferences are user-scoped; require auth
  • docs/architecture/authentication.md — updated as part of this PRD's implementation

Last updated: 2026-05-22

Loading…