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):
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')
endClerk 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:
return authenticate_user! if authenticate_user! # ← reads as nonsenseThis 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:
def resolve(input:)
result = ::SomeInteraction.call(
user: context[:current_user],
**input
)
# ... handle result
endThe 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:
result.fail(error: 'You can not update this user')This becomes a GraphQL response like:
{ "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:
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 activitycommunity_insights— returns personalized community engagement statsrecommended_communities— returns personalized recommendationsuser_allies— returns a user's social connectionsauthz_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:
apollo-client.tserrorLink (lines 67-91): catches network 401s, callsconsole.error('[Apollo] Session expired'), captures to Sentry, does nothing else. No redirect, no cache reset, no token refresh.apollo-client.tserrorHandler (lines 111-131): switch statement that callsconsole.erroron each branch. No redirect, no toast, no UX surface for 403.- Router guard (
router/index.tslines 213-237): only checks "is there a Clerk token?" Never checks role. A non-admin user navigating to/adminpasses the guard, loadsAdmin.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)
| # | File | Line | Current code | Recommended fix | Category |
|---|---|---|---|---|---|
| B-C-1 | app/graphql/mutations/ai/get_advice.rb | 11-19 | def resolve(user_context:) proceeds without checking current_user | Add raise GraphQL::ExecutionError.new('UNAUTHORIZED', extensions: { code: 'UNAUTHORIZED' }) unless context[:current_user] as first line of resolve | missing_authn |
| B-C-2 | app/graphql/mutations/ai/generate_milestones.rb | 12-21 | Same — no auth check | Same fix as B-C-1 | missing_authn |
| B-C-3 | app/graphql/mutations/ai/refine_description.rb | 15-23 | Same — no auth check | Same fix as B-C-1 | missing_authn |
| B-C-4 | app/graphql/mutations/ai/get_insight.rb | 15-35 | Same — no auth check | Same fix as B-C-1 | missing_authn |
| B-C-5 | app/graphql/types/query_type.rb | 222-226 | authz_check query has no auth check; can be queried anonymously to leak any user's admin status | Add authn check; require self-or-admin to read another user's authz state | missing_authn |
| B-C-6 | app/graphql/types/query_type.rb | 432-466 | ally_activity_feed has no auth check; exposes private ally relationships and activity | Add authn check; require user_id == current_user.public_id || current_user.admin | missing_authn |
| B-C-7 | app/graphql/types/query_type.rb | 468-483 | community_insights has no auth check; leaks per-user community engagement data | Same as B-C-6 | missing_authn |
| B-C-8 | app/graphql/types/query_type.rb | 551-567 | user_allies has no auth check; returns a user's social connections | Same as B-C-6 | missing_authn |
| B-C-9 | app/interactions/social/toggle_goal_event_encouragement.rb | 8-28, 38 | Treats current_user.blank? as a valid anonymous encouragement (return false if current_user.blank?) — silent fall-through; no error raised | Replace with explicit result.fail(error: 'UNAUTHORIZED') and add extension code | missing_authn |
High (12)
| # | File | Line | Current code | Recommended fix | Category |
|---|---|---|---|---|---|
| B-H-1 | app/graphql/types/query_type.rb | 630-634 | authenticate_user! returns a GraphQL::ExecutionError instead of raising | Convert to raise GraphQL::ExecutionError.new('UNAUTHORIZED', extensions: { code: 'UNAUTHORIZED' }). Update all ~30 call sites in the same migration. | bang_pattern |
| B-H-2 | app/graphql/types/query_type.rb | 636-640 | authorize_admin! same problem | Same fix as B-H-1 with 'FORBIDDEN' code | bang_pattern |
| B-H-3 | app/graphql/mutations/report_content.rb | 13-29 | No explicit auth check in mutation, no visible check in interaction; anonymous reports possible | Add raise UNAUTHORIZED unless current_user at top of resolve | missing_authn |
| B-H-4 | app/graphql/mutations/add_goal_event.rb | 21-42 | Passes current_user to interaction with no presence check; interaction has none either | Add raise UNAUTHORIZED unless current_user | missing_authn |
| B-H-5 | app/graphql/mutations/toggle_feedback_vote.rb | 13-26 | No visible auth check; interaction may allow anonymous votes | Add explicit check | missing_authn |
| B-H-6 | app/graphql/mutations/review_content_report.rb | 11-23 | No explicit admin check in mutation | Add raise FORBIDDEN unless current_user&.admin | missing_authz |
| B-H-7 | app/graphql/mutations/delete_notification.rb | 13 | No visible auth check | Add raise UNAUTHORIZED unless current_user | missing_authn |
| B-H-8 | app/graphql/mutations/check_in_habit.rb | 14-31 | Passes current_user to interaction without presence check | Add explicit check | missing_authn |
| B-H-9 | app/graphql/mutations/use_streak_freeze.rb | 14-31 | Same | Same | missing_authn |
| B-H-10 | app/graphql/mutations/create_community.rb | 19-37 | Same | Same | missing_authn |
| B-H-11 | app/graphql/types/query_type.rb | 382-394 | goal_progress_data has authn check but no resource ownership verification | Add raise FORBIDDEN unless goal.user_id == current_user.id || current_user.admin | no_ownership_check |
| B-H-12 | app/graphql/types/query_type.rb | 546-549 | recommended_communities has no auth check | Add authn check | missing_authn |
Medium-High wrong-code violations (8)
These return generic strings; frontend can't differentiate authn from authz:
| # | File | Line | Current code | Recommended fix |
|---|---|---|---|---|
| B-W-1 | app/interactions/user_identity/update_user.rb | 72-79 | result.fail(error: 'You can not update this user') | result.fail(error: 'FORBIDDEN', code: 'FORBIDDEN') |
| B-W-2 | app/interactions/goal_tracking/add_goal.rb | 113-115 | result.fail(error: 'You can not add a goal for this user') | Same |
| B-W-3 | app/interactions/goal_tracking/update_goal.rb | 109-111 | result.fail(error: 'You can not add a goal for this user') | Same |
| B-W-4 | app/interactions/social/create_community_post.rb | 14 | result.fail(error: 'Unauthorized') (generic, even when user IS authenticated) | result.fail(error: 'FORBIDDEN', code: 'FORBIDDEN') |
| B-W-5 | app/interactions/content_moderation/review_content_flag.rb | 8 | unless current_user&.admin then result.fail(error: 'Unauthorized') | Should be 'FORBIDDEN' since user IS authenticated |
| B-W-6 | app/interactions/social/join_community.rb | 11, 61 | Generic error string | Same as B-W-1 |
| B-W-7 | Most other failing interactions | various | Mix of 'Unauthorized', 'You can not...', 'Access denied' strings | Standardize on 'UNAUTHORIZED' and 'FORBIDDEN' with extension codes |
| B-W-8 | app/interactions/feedback/create_feedback_post.rb | 8 | '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:
| # | File | Line | Issue |
|---|---|---|---|
| B-M-1 | app/graphql/mutations/update_user.rb | 7, 21 | Accepts user_id; relies entirely on interaction check |
| B-M-2 | app/graphql/mutations/add_goal.rb | 8 | Accepts user_id as required argument |
| B-M-3 | app/graphql/mutations/update_goal.rb | 9 | Same |
| B-M-4 | app/graphql/mutations/join_community.rb | 7 | Same |
| B-M-5 | app/graphql/mutations/create_community_post.rb | 6 | Same |
| B-M-6 | app/graphql/mutations/update_user_photo.rb | 7 | Optional user_id; defaults to current_user if omitted |
| B-M-7 | app/graphql/mutations/store_device_token.rb | 7 | Optional 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)
| # | File | Line | Severity | Issue | Fix |
|---|---|---|---|---|---|
| B-L-1 | app/services/clerk_jwt_verifier.rb | 16-21 | LOW-MED | No 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-2 | app/controllers/graphql_controller.rb | 72-84 | LOW-MED | First-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)
| # | File | Line | Severity | Current behavior | Recommended fix |
|---|---|---|---|---|---|
| F-C-1 | src/apollo-client.ts | 111-131 | CRITICAL | errorHandler 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-2 | src/apollo-client.ts | 67-91 | CRITICAL | errorLink 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-3 | src/router/index.ts | 213-237 | CRITICAL | Router 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)
| # | File | Line | Severity | Issue | Fix |
|---|---|---|---|---|---|
| F-H-1 | src/apollo-client.ts | 114-122 | HIGH | Error 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-2 | src/apollo-client.ts | 27-55 | HIGH | authLink swallows Clerk session errors silently (try/catch with empty body); request proceeds unauthenticated | Log when token fetch fails; if Clerk reports user is signed out, trigger logout flow |
| F-H-3 | src/views/Admin.vue | 270-271 | HIGH | useQuery(ADMIN_STATS_QUERY) runs unconditionally; non-admin users trigger 403 errors in the background | Guard with enabled: computed(() => !!isAdmin.value) |
| F-H-4 | src/helpers/session.js | 50-66 | HIGH | logout() is defined but never called from Apollo error handlers | Wire logout() into the new errorLink path on 401 |
| F-H-5 | src/constants/graphql/*.js | all | HIGH | No standard error-handling pattern across queries/mutations | Add a useApiError() composable; require all useQuery/useMutation calls to wire it |
| F-H-6 | src/views/SignIn.vue | 226-250 | HIGH | Stale Clerk session detection runs only on the sign-in page; doesn't run when a user reloads a protected route mid-session | Move to app initialization (App.vue or main.ts) so it runs on every load |
Medium (5)
| # | File | Line | Issue | Fix |
|---|---|---|---|---|
| F-M-1 | src/views/Goal.vue | 496-511 | Conflates 404 (not found) with 403 (not yours); both redirect to public goal view | Differentiate by extension code; only redirect on NOT_FOUND |
| F-M-2 | src/views/Community.vue | 397-399 | Catch-all error handler masks auth failures behind "Could not join" toast | Differentiate by code |
| F-M-3 | src/router/index.ts | 220-224 | Fallback token in localStorage is checked but never validated; can be expired/invalid | Validate on first call; clear and redirect on failure |
| F-M-4 | src/sentry.ts | 26-34 | setSentryUser() called after sync but never on logout or 401 | Add Sentry.setUser(null) to logout flow |
| F-M-5 | src/composables/useClerkSync.ts | 16-24 | setSynced() 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)
| # | File | Line | Severity | Issue | Fix |
|---|---|---|---|---|---|
| C-1 | src/apollo-client.ts | 42-48 | MEDIUM | Multi-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-2 | app/services/clerk_jwt_verifier.rb | 45-58 | MEDIUM | JWKS fetch has no fallback to stale cache if Clerk API is unreachable | Add stale-cache fallback in rescue branch |
| C-3 | app/controllers/graphql_controller.rb | 89-99 | LOW (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-4 | app/controllers/graphql_controller.rb | 52 | LOW | Header lookup is case-sensitive (request.headers['SessionToken']); a client sending session-token would be silently treated as unauthenticated | Rails 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:
- AI mutations have no authentication. Anyone can hit the GraphQL endpoint and burn LLM budget.
- 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
- Backend has bulletproof helpers that raise (not return) and that produce consistent GraphQL extension codes.
- Frontend has a single error-handling surface that distinguishes 401 from 403 and routes UX accordingly.
- Every existing violation gets a concrete fix with file path, line number, and recommended code.
- Backwards-compatible incremental change — no Pundit-scale rewrite, no Interaction gem replacement, no auth library change.
- 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:
# 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
endThe 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:
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
endThe 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):
| Code | Meaning | When to use |
|---|---|---|
UNAUTHORIZED | No valid identity. The user is not signed in (or session expired). | Missing current_user, expired JWT, missing token |
FORBIDDEN | Identity is valid but lacks permission. | Non-admin hitting admin field, non-owner editing resource, role check failure |
NOT_FOUND | Resource 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):
# 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):
# 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):
# 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)
endFor mass assignment (B-M-1 through B-M-7):
# 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
end2.3.4 Backend test requirements
Add an RSpec shared example: 'requires authentication' and 'requires admin'. Every mutation spec must include one of:
it_behaves_like 'requires authentication'
it_behaves_like 'requires admin'
it_behaves_like 'requires resource ownership', :goalAdd 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:
// 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:
// 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:
// 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:
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 handlingsrc/views/Admin.vue— F-H-3 (enabled: !!isAdmin)src/helpers/session.js— F-H-4 (wirelogout()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 (useuseApiError)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)
- Add the four AI mutation auth checks (B-C-1 through B-C-4)
- Add auth checks to
ally_activity_feed,community_insights,recommended_communities,user_allies,authz_check(B-C-5 through B-C-8) - Fix
toggle_goal_event_encouragementsilent 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)
- Add
Authorizationconcern withrequire_auth!,require_admin!,require_owner! - Update
BaseQuery,BaseMutation,BaseFieldto include the concern - Migrate every existing call site from old helpers to new ones (~30 sites in
query_type.rb, ~15 mutations) - Mark old helpers as deprecated with logged warnings
- Add RSpec shared examples (
'requires authentication','requires admin','requires resource ownership') - Add CI check that fails on missing auth shared examples in mutation specs
- Standardize all interaction error returns on
'UNAUTHORIZED'/'FORBIDDEN'with codes (B-W-1 through B-W-8)
Phase 3 — Frontend conventions (1 sprint)
- Create
useApiErrorcomposable - Rewrite
apollo-client.tserrorLink with token-refresh-then-redirect logic - Add role-based router guards (F-C-3)
- Wire up multi-tab sign-out listener
- Update Admin.vue, Goal.vue, Community.vue to use
useApiError - Update Apollo provider error handler to delegate to
useApiError - Fix
Sentry.setUser(null)on logout
Phase 4 — Mass-assignment hardening (1 sprint)
- Audit all
user_idarguments in mutations (B-M-1 through B-M-7) - Remove
user_idargument from mutations that operate on the current user - For mutations that legitimately accept other users (admin operations), enforce admin check at the mutation layer
- Update GraphQL schema, regenerate frontend types, fix any frontend callers
Phase 5 — Polish and edge cases (1 sprint)
- Clerk JWKS stale-cache fallback (C-2)
- First-sign-in race condition rescue (B-L-2)
- Fallback auth rate limiting (C-3)
- Stale Clerk session detection moved to App.vue (F-H-6)
- 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, stilldelegate_input, stillresult.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
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.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.
How to handle the existing
bugfix/unauthorized-productionsymptom? 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.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# publiccomment. Forces an opt-out, not opt-in.Resource ownership for nested resources? A
Milestonebelongs to aGoalbelongs to aUser. Doesrequire_owner!(milestone)walk up the chain? Recommendation: yes — the helper checksuser_idon the resource and falls back togoal.user_idand similar. Defined per-model in a smallOwnableconcern.Should the standalone admin app PRD's RBAC roles slot into
require_admin!? Yes — once theadmin_rolestable exists,require_admin!becomesrequire_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 withrequire_auth!,require_admin!,require_owner!rails_api/spec/support/auth_shared_examples.rb— RSpec shared examplesbin/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 Authorizationrails_api/app/graphql/types/base_mutation.rb— include Authorizationrails_api/app/graphql/types/base_field.rb— include Authorizationrails_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-1rails_api/app/graphql/mutations/ai/generate_milestones.rb— B-C-2rails_api/app/graphql/mutations/ai/refine_description.rb— B-C-3rails_api/app/graphql/mutations/ai/get_insight.rb— B-C-4rails_api/app/graphql/mutations/report_content.rb— B-H-3rails_api/app/graphql/mutations/add_goal_event.rb— B-H-4rails_api/app/graphql/mutations/toggle_feedback_vote.rb— B-H-5rails_api/app/graphql/mutations/review_content_report.rb— B-H-6rails_api/app/graphql/mutations/delete_notification.rb— B-H-7rails_api/app/graphql/mutations/check_in_habit.rb— B-H-8rails_api/app/graphql/mutations/use_streak_freeze.rb— B-H-9rails_api/app/graphql/mutations/create_community.rb— B-H-10rails_api/app/graphql/mutations/update_user.rb— B-M-1 (remove user_id)rails_api/app/graphql/mutations/add_goal.rb— B-M-2rails_api/app/graphql/mutations/update_goal.rb— B-M-3rails_api/app/graphql/mutations/join_community.rb— B-M-4rails_api/app/graphql/mutations/create_community_post.rb— B-M-5rails_api/app/graphql/mutations/update_user_photo.rb— B-M-6rails_api/app/graphql/mutations/store_device_token.rb— B-M-7rails_api/app/interactions/social/toggle_goal_event_encouragement.rb— B-C-9rails_api/app/interactions/user_identity/update_user.rb— B-W-1rails_api/app/interactions/goal_tracking/add_goal.rb— B-W-2rails_api/app/interactions/goal_tracking/update_goal.rb— B-W-3rails_api/app/interactions/social/create_community_post.rb— B-W-4rails_api/app/interactions/content_moderation/review_content_flag.rb— B-W-5rails_api/app/interactions/social/join_community.rb— B-W-6rails_api/app/interactions/feedback/create_feedback_post.rb— B-W-8rails_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 truthionic_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 checkionic_frontend/src/views/Goal.vue— F-M-1ionic_frontend/src/views/Community.vue— F-M-2ionic_frontend/src/sentry.ts— F-M-4ionic_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:
- Spec coverage — every mutation in
rails_api/spec/graphql/mutations/has at least oneit_behaves_like 'requires authentication'or'requires admin'. CI scriptbin/check_auth_specsenforces this. - Manual test 1: AI mutation without auth —
curlthe GraphQL endpoint with no SessionToken, send agetAdvicemutation, expect{ "errors": [{ "message": "...", "extensions": { "code": "UNAUTHORIZED" } }] }. Today this returns{ "data": { "advice": "..." } }. - Manual test 2: Non-admin hitting /admin — sign in as a non-admin user, navigate to
/admin. Expect to land on/dashboard?denied=adminwith an inline "You don't have access to the admin area" banner. Today the user lands on/adminwith an Access Denied panel and an unrecoverable URL. - 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. - 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.
- 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.
Related PRDs
docs/product/standalone-admin-app-prd.md— depends on this PRD's RBAC-friendly helper shapedocs/product/dedicated-ai-service-prd.md— auth on AI mutations is a prerequisite for cost tracking; LiteLLM virtual keys assume per-user attributiondocs/product/meet-coach-prd.md— persona preferences are user-scoped; require authdocs/architecture/authentication.md— updated as part of this PRD's implementation
Last updated: 2026-05-22