# Repo map — Authentication & Authorization (ground truth)

The survey behind this course. Every lesson's `file:line` anchors resolve here. When code moves, fix
this file first, then the lessons. Grouped by the request's journey through the gates.

**The two-layer design, in one breath:** authN = JWKS-verified JWT in a gRPC interceptor. authZ =
three tiers — (1) coarse **role gate** (`rbacDecider`), (2) **granular permissions** scoped by
**location subtree**, (3) **Postgres RLS** isolating tenants by `resource_path`. Authoritative rules:
`.claude/rules/security.md` (but see the "panic" correction in §3).

---

## 1. The token — what a Manabie JWT carries

- **Claims struct:** `internal/golibs/interceptors/auth_claims.go` — `CustomClaims` (`:13`) embeds
  `jwt.Claims` + `*FirebaseClaims` + `Hasura`(`:91`) + `Manabie`(`:101`) + `*Auth0Claims`(`:125`) + …
- **`ManabieClaims`** (`auth_claims.go:101`) — the tenant-critical claim:
  `AllowedRoles []string`, `DefaultRole`, `UserID`, `SchoolIDs []string`, `UserGroup`,
  **`ResourcePath`** (= org id), `TenantID`. Comment: *"exact value as HasuraClaims but without
  prefix"* — Hasura gets the same values as `x-hasura-*` for its own RLS.
- **Context accessors** (`internal/golibs/interceptors/auth.go`): `UserIDFromContext` (`:354`),
  `UserGroupFromContext` (`:381`), `UserRolesFromContext` (`:398`), `JWTClaimsFromContext` (`:405`),
  `ResourcePathFromContext` (`:466`, reads `Manabie.ResourcePath`), `OrganizationFromContext` (`:438`).
  Setters: `ContextWithUserID` (`:361`), `ContextWithUserGroup` (`:388`), `ContextWithUserRoles`
  (`:393`), `ContextWithJWTClaims` (`:475`).

## 2. Minting & verifying — Shamir, the token authority

- **Shamir** (`cmd/server/shamir`, `internal/shamir`) is the central **verifier + minter (exchanger)
  + JWKS host**. It does *not* run the `Auth` interceptor (`cmd/server/shamir/gserver.go:44`) — auth
  is intrinsic to its RPCs.
- **JWKS served** at `/.well-known/jwks.json` + OIDC discovery — `internal/shamir/transports/rest/jwks.go:29,13`.
  Every other service's `TokenVerifier` fetches this to validate Manabie tokens.
- **Signing keys:** a glob of SOPS-encrypted RSA private keys (`keys_glob`, `primary_key_file`) —
  `internal/shamir/configurations/cfg.go:28,34,107`. (Despite the name, standard multi-key JWT
  signing, **not** Shamir secret-sharing.)
- **Exchange logic** `exchangeVerifiedToken` (`internal/shamir/transports/grpc/token_reader.go:139`):
  verify external token → look up user (`UserRepoV2.GetByAuthInfo`; `DecouplingUserAndAuthDB` unleash
  flag picks `AuthDB` vs `DB`) → reject if `DeactivatedAt` (`ErrDeactivatedUser` `:180`) → fill
  `TokenInfo{DefaultRole, AllowedRoles=[group], SchoolIds, UserGroup, ResourcePath}` from the DB user
  → sign a new Manabie JWT.
- **Minting** `internal/shamir/services/verifier.go`: `ExchangeVerifiedToken` (`:98`),
  `generateNewToken` (`:166`, Issuer `"manabie"`, embeds `Manabie`+`Hasura`+`Auth0`+`Salesforce`;
  expiry = original + 5s), `signNewToken` (`:210`, RS256 primary key). Inside a minted token:
  subject=userID, iss=`manabie`, `manabie` claim `{user_id, school_ids, default_role, allowed_roles,
  user_group, resource_path, tenant_id}` (`verifier.go:180-188`).
- **RPCs** (`token_reader.go`): `ExchangeToken` (`:213`), `VerifyToken`/`VerifyTokenV2`,
  `ExchangeSalesforceIDTokenToManabieToken`, `GenerateFakeToken`, `GetAuthInfo`. Plus
  `ExchangeExternalToken`, OpenAPI/Salesforce/named-credential flows.
- **Login** (password → Firebase idToken): `LoginInAuthPlatform`
  (`internal/usermgmt/pkg/interceptors/auth.go:344`) POSTs to
  `constant.IdentityToolkitURL` (`common_def.go:89`, `identitytoolkit.googleapis.com/...signInWithPassword`).
- **usermgmt AuthService**: `ExchangeToken`
  (`internal/usermgmt/modules/user/port/grpc/auth.go:61` → proxies Bob → Shamir), `GetOAuthUrl`
  (OAuth2 **PKCE**, `internal/usermgmt/modules/auth/core/service/domain_auth.go:158,212-227`),
  `ResetPassword`. All in `ignoreAuthEndpoint`.

## 3. The auth interceptor & the role gate (RBAC)

- **Two interceptor packages, same type names (footgun):** the legacy
  `internal/golibs/interceptors` (single-role `GroupDecider.Check → string`) and the current
  `internal/usermgmt/pkg/interceptors` (multi-role `→ []string`). **Every `cmd/server/<svc>/auth.go`
  uses the usermgmt one.** The golibs one still hosts the shared `TokenVerifier`, `CustomClaims`, and
  context accessors.
- **`Auth.UnaryServerInterceptor`** (`internal/usermgmt/pkg/interceptors/auth.go:79`):
  `skipAuthMethods[FullMethod]` → bypass; else `verify(ctx)` (`:120`, reads `md.Get("token")`)
  → `ContextWithUserID(claims.Subject)` + `ContextWithUserGroup` (if `Manabie.UserGroup`) +
  `ContextWithJWTClaims` → `groupDecider.Check` → `ContextWithUserGroup(groups[0])` +
  `ContextWithUserRoles(groups)`.
- **`TokenVerifier`** (`internal/golibs/interceptors/auth.go:184`): JWKS-based. `Verify` (`:269`)
  parses with `jwt.ParseSigned`, matches `keySet`, **re-fetches JWKS once on failure**, validates
  `Issuer`/`Audience`/`Time`. Multi-issuer: `Auth.verify` tries each verifier, first success wins.
  Issuers: `[]TokenIssuerConfig{Issuer, Audience, JWKSEndpoint}` (`configs/common.go:179-184`).
- **`GroupDecider.Check`** (`internal/usermgmt/pkg/interceptors/auth.go:201`) — the role gate:
  - method **absent** from `AllowedGroups` → `sttNoDeciderProvided` = **`PermissionDenied` at runtime**
    (`:72,207`). ⚠️ **`.claude/rules/security.md` says "panic at startup" — that is WRONG.** No startup
    validation exists.
  - roles empty → `sttDeniedAll` (`:214`). `allowedGroups == nil` → **allow any authenticated user**
    with ≥1 role (`:219`). `len == 0` → `sttDeniedAll` (`:224`). else membership test → `sttNotAllowed`.
  - roles resolved by `GroupFetcher` = `RetrieveUserRoles`/`V2`/`V3` (`:242/266/281`) reading the DB,
    filtered to `constant.AllowListRoles`; deactivated → `sttDeactivatedUser` (`:293`).
- **`rbacDecider`** = `map[string][]string` (method → roles) in each `cmd/server/<svc>/auth.go`, wired
  into `GroupDecider.AllowedGroups`. Roles: `constant.Role*` (`internal/usermgmt/pkg/constant/common_def.go:17-43`,
  ~27 roles; registerable = `AllowListRoles` `:137`).
- **`ignoreAuthEndpoint`** → `skipAuthMethods` (bypass JWT entirely: health, `ExchangeToken`,
  `GetOAuthUrl`, `ResetPassword`). **`fakeJwtCtxEndpoint`** → `FakeJwtContext`
  (`internal/golibs/interceptors/internal_api.go:10,32-57`): synthesizes School-Admin `CustomClaims`
  from the request body (`ResourcePath = req.GetOrganizationId()`) — **service-to-service auth** for
  internal (NATS/Kafka-originated) calls.
- **Interceptor order** (spike `gserver.go:95`): default (ctxtags/zap/payload/ctx-cancel) → **auth
  (verify + role gate)** → fakeSchoolAdmin → activity-log.

## 4. Granular permissions

- **Schema** (`migrations/bob/1182_migrate.up.sql`, all RLS-protected):
  `permission(permission_id, permission_name, …, resource_path)` (`:1`),
  `role` (`:17`), `permission_role` (`:33`, M:N perm↔role),
  `user_group` (`:51`), `granted_role(user_group_id, role_id)` (`:67`),
  **`granted_role_access_path(granted_role_id, location_id)`** (`:86`, scopes a grant to a location),
  `user_group_member(user_id, user_group_id)` (`:104`).
- **Denormalized fast-path** `granted_permission` (`migrations/bob/1309_migrate.up.sql:1`):
  `(user_group_id, role_id, role_name, permission_id, permission_name, location_id, resource_path)` +
  **RESTRICTIVE** RLS (`:20`).
- **The `granted_permissions` VIEW** (`migrations/auth/1001_migrate.up.sql`) joins
  user_group_member→user_group→granted_role→role→permission_role→permission→granted_role_access_path→
  locations and **expands to the location subtree** with `l1.access_path ~~ (l.access_path || '%')`.
- **Runtime resolve:** `GrantedPermissionRepo.FindLocationIDsByUserIDAndPermissionName`
  (`internal/bob/repositories/granted_permission.go:14`) → the location IDs where a user holds a named
  permission. Exposed as gRPC `InternalAccessControlService.RetrieveUserGrantedLocations`
  (`internal/bob/services/accesscontrol/internal_access_control.go:24`).
- Permission names are dotted strings: `user.user.read/write`, `master.location.read/write`.

## 5. Locations & access_paths

- `locations(location_id, name, parent_location_id, location_type, access_path, resource_path)` —
  `migrations/bob/1124_migrate.up.sql:1` (+ self-FK `1144`). `location_types` (self-ref tree) —
  `migrations/bob/1144:1`.
- **`access_path` = materialized path** of `location_id`s joined by `/`. Backfilled by recursive CTE
  (`migrations/bob/1157`), indexed `text_pattern_ops` for prefix `LIKE`.
- **Subtree rule:** a grant at `L` covers every location whose `access_path` starts with
  `L.access_path` — `… ~~ (l.access_path || '%')`.
- `user_access_paths(user_id, location_id, access_path, resource_path)` — `migrations/bob/1147:1`
  (which locations a user belongs to).
- **`LocationRestricted` interceptor** (`internal/mastermgmt/pkg/interceptors/location_restricted.go:31`):
  between auth and handler — empty `req.LocationIds` → require org-root; else every requested location
  must be granted, else `PermissionDenied`.

## 6. RLS & multi-tenancy

- **Session vars set per acquire:** `internal/golibs/database/pool_gcp.go` —
  `poolconfig.BeforeAcquire = setPostgres` (`:63`); `setPostgres` (`:161`) reads
  `JWTClaimsFromContext(ctx)` and runs `SELECT set_config('permission.resource_path',$1,false),
  set_config('permission.user_group',$2,false), set_config('app.user_id',$3,false)` (`:176`),
  verifying the echo and refusing the connection (return `false`) on mismatch (`:189-206`).
- **Predicate functions** (`migrations/auth/1001_migrate.up.sql`):
  `autofillresourcepath()` (`:1`) = `current_setting('permission.resource_path','t')`, used as the
  DEFAULT for `resource_path` columns (auto-stamp tenant on insert);
  `permission_check(resource_path, table_name)` (`:14`) = `$1 = current_setting('permission.resource_path')`.
- **Policies:** tenant isolation `CREATE POLICY rls_<t> ON <t> USING (permission_check(resource_path,'<t>'))`
  (`migrations/bob/1182:12`, for `permission`/`role`/…); tables `ENABLE` + **`FORCE ROW LEVEL SECURITY`**
  (owner bound too); many have an `AS RESTRICTIVE` twin.
  Location-aware policies (`migrations/auth/1001`): `rls_users_select_location` etc. — a `users` row is
  visible only if `granted_permissions ⋈ user_access_paths` shows the caller
  (`current_setting('app.user_id')`) holds `user.user.read/write` on that user's location;
  `rls_users_permission_v4` = self-visibility (`app.user_id = user_id`); `rls_locations_location`
  requires `master.location.read`.
- **`resource_path` = tenant/org:** `organizations(organization_id, tenant_id, domain_name,
  resource_path)`; `organization_auths(organization_id, auth_project_id, auth_tenant_id)` maps org →
  Firebase project (Shamir's `appendAdditionalIssuer`). Seed orgs use negative ints
  (`-2147483648` = Manabie).
- **`rls_scan`** (`cmd/server/rls_scan/rls_scan.go`, every 24h): `database.ScanRLS`
  (`internal/golibs/database/scan_rls.go:11`) enumerates every public table, checks each has the
  expected `permission_check` RLS vs `migrations/public_tables.json` allow-list; flags drift.

---

## 7. End-to-end trace — `RetrieveStudentAssociatedToParentAccount` (Parent-only read)

```
1. TOKEN     client sends Manabie JWT in gRPC md "token" (Shamir-minted; manabie claim: user_id=parentID, resource_path=org, user_group=Parent)
2. VERIFY    Auth.UnaryServerInterceptor (usermgmt/interceptors/auth.go:79) → verify vs JWKS → ContextWith{UserID,JWTClaims}
3. ROLE GATE rbacDecider["…/RetrieveStudentAssociatedToParentAccount"] = {RoleParent}; GroupDecider.Check (auth.go:201) requires Parent else PermissionDenied
4. OWNERSHIP handler (user_reader_retrieve_student_associated_to_parent.go:15): userID := UserIDFromContext(ctx) — uses caller's OWN id, not req → a parent can only see their own children
5. REPO      LegacyStudentRepo.GetStudentsByParentID (legacy_student.go:185): joins students/student_parents/parents, all deleted_at IS NULL, parent_id=$1
6. RLS       pool BeforeAcquire=setPostgres already set permission.resource_path/user_group + app.user_id from JWT → rls_users_* policies filter by tenant (permission_check) + per-location user.user.read + self-visibility
                                                    row survives only if repo WHERE **and** RLS both pass
```

## Surprising / repo-specific
- **Two `interceptors` packages** with clashing type names — services use the usermgmt one.
- **rbac "panic at startup" is NOT implemented** — runtime `PermissionDenied` (`security.md` is wrong).
- **Faked JWTs** for service-to-service (`FakeJwtContext`; Shamir `GenerateFakeToken` non-prod).
- **Hasura RLS coexists** — `x-hasura-*` claims minted alongside Manabie; same `resource_path`/`user_id`.
- **Unleash gates the auth path** — `DecouplingUserAndAuthDB` (bob DB vs separate auth DB per org),
  `User_Auth_ManabieRole`, `User_Auth_ExchangeAPlus`.
- **Auth0** (`auth0|<id>` subjects) + **Salesforce** ID-token/named-credential exchange flows in Shamir.
