# Mobile Auth and RBAC Boundary

| 항목 | 값 |
|---|---|
| Status | Active APP auth/RBAC boundary |
| Owner | APP auth: 주우철 · backend auth/RBAC counterpart: 정정일 |
| Last verified | 2026-07-15 KST |
| Supersedes | `backend-auth-placeholder` decision-only boundary |

이 문서는 APP auth/RBAC 구현 범위와 backend/auth/RBAC decision 범위를 분리한다. 과거 `backend-auth-placeholder`는 decision-only gate였으며, 현재는 shared APP bearer-token contract와 provider/UI pending 범위를 구분한다.

## Status

- Auth provider와 token/session shape는 확정 상태다. Backend는 Spring Security + 자체 JWT를 사용한다.
- Web은 httpOnly cookie session을 사용하고, APP은 `X-Zerro-Platform: app` 요청에서 bearer-token body(`MobileAuthSession`)를 받는다.
- 현재 APP은 shared auth/session token client와 SecureStore-backed token boundary, `/login` runtime auth binding을 가진다. `EXPO_PUBLIC_API_BASE`가 없으면 local mock auth를 쓰고, 값이 있으면 Kotlin Spring backend auth/session endpoint로 승격한다.
- 현재 APP은 `/login`에서 credential submit, session restore, logout, `/session/roles` 기반 multi-role context selection, access-denied/session-expired surface 후보를 검증한다. backend mode는 local demo seed account로 smoke 가능하며, feature 화면의 backend authorization migration은 구현하지 않는다.
- 현재 APP은 온보딩에서 role을 선택하고 `/role/[role]` route로 진입하는 mock-first UX다. 이 role picker는 production auth가 아니라 dev/mock entry다.
- multi-role은 backend `user_role` + JWT `roles` claim + `/session/me?role=...` 기반으로 보유 role 중 active role을 고르는 형태다.
- multi-company active company switching, duties enum 완성, 가입/초대/비밀번호 찾기/OAuth/SMS/SSO는 후속 Open Decision이다.
- Figma node 없음: Auth/RBAC는 현재 화면 node가 아니라 backend contract와 route guard decision 영역이다.
- `backend-auth-placeholder`의 과거 `missing:*` marker는 APP `testID`가 아니라 decision backlog marker다.

## Current Mobile Boundary

| 영역 | 현재 허용 | 금지 |
|---|---|---|
| Role entry | 온보딩 role 선택, `/login` credential submit, 보유 role context 선택, known role route 진입 | 실제 signup/provider 구현 |
| Token boundary | `apps/mobile/src/shared/api`의 runtime APP bearer login/refresh/logout/session/roles client | 화면별 ad hoc token 저장, raw URL 조립, provider SDK 직접 결합 |
| Invalid role | `role.invalid.screen` fallback과 onboarding 복귀 | 임의 role을 자동 보정하거나 조용히 home으로 보내기 |
| Role visibility | `docs/10-product/RBAC_MATRIX.md` 기준 role별 surface/CTA 표시 또는 숨김 | backend authorization shape를 화면 코드에서 임의 결정 |
| Permission failure | mock surface에서는 disabled reason, access-denied, session-expired 후보로 표현 | server session, multi-company rule 임의 구현 |
| API error | `UNAUTHORIZED`, `FORBIDDEN`은 화면 상태 또는 breadcrumb only | Sentry issue로 auth/permission 정상 실패를 noise화 |

## Existing Verified Surface

현재 실제 APP surface로 검증된 Auth/RBAC 경계는 unsupported role fallback, `/login` session binding, 기존 More의 session identity와 logout이다. local smoke는 mock auth를 유지하고, backend mode는 `EXPO_PUBLIC_API_BASE`를 통해 같은 screen contract를 Spring `/auth`·`/session`으로 승격한다. backend mode 검증용 local demo credential은 `SPRING_PROFILES_ACTIVE=local`과 `ZERRO_AUTH_DEMO_SEED_ENABLED=true`가 함께 있을 때 생성되는 `ops@zerro.dev` / `zerro1234`다.

| Surface | Route / entry | Stable testID | Coverage |
|---|---|---|---|
| Invalid role fallback | `zerro://role/unknown` | `role.invalid.screen`, `role.invalid.title`, `role.invalid.back-to-onboarding.cta` | `invalid-role-fallback.yaml`, `TEST_COVERAGE_GRAPH.md` |
| Role onboarding | 앱 root onboarding | `onboarding.role-select.screen`, `onboarding.role.*.cta` | `role-routing.yaml` |
| Login session binding | `zerro://login` | `auth.login.screen`, `auth.login.email.input`, `auth.login.password.input`, `auth.login.submit.cta`, `auth.context-selection.screen`, `auth.context-selection.role.*.cta`, `auth.context-selection.reset.cta`, `auth.session-restore.card`, `auth.session-restore.logout.cta`, `auth.login.role.*.cta`, `auth.login.back-to-onboarding.cta` | `login-entry.yaml`, `TEST_COVERAGE_GRAPH.md` |
| Existing More account/logout | `/role/emitter`, `/role/dispatcher`, `/role/processor`의 기존 More | `{role}.more.account.company`, `{role}.more.account.name`, `{role}.more.account.role`, `{role}.more.logout.cta`, `{role}.more.logout.error` | `auth-more-logout-backend.yaml`, `role-shell-coverage.yaml`, `TEST_COVERAGE_GRAPH.md` |

## Current APP Surface Contract

아래 testID와 상태명으로 APP auth surface를 검증한다. 단, 가입/초대/비밀번호 찾기/OAuth/SMS/SSO와 multi-company switching은 이 문서에서 확정하지 않는다.

| Surface | 목적 | Stable testID 후보 | 허용된 상태 |
|---|---|---|---|
| Credential login form | email/password mock credential submit | `auth.login.email.input`, `auth.login.password.input`, `auth.login.submit.cta`, `auth.login.status.message` | `loading`, `validation-error`, `unauthorized`, `forbidden` |
| Role/context selection | 로그인 후 보유 role이 2개 이상일 때 active role 선택 | `auth.context-selection.screen`, `auth.context-selection.role.*.cta`, `auth.context-selection.reset.cta` | `ready`, `forbidden`, `logout` |
| Session restore/logout | local token이 있을 때 이전 업무로 이어가거나 local token을 정리 | `auth.session-restore.card`, `auth.session-restore.continue.cta`, `auth.session-restore.logout.cta` | `ready`, `logout` |
| Existing More account/logout | 인증 진입에서는 active session identity를 표시하고 logout 뒤 login route로 교체. mock 직접 진입은 검수 fixture를 표시 | `{role}.more.account.*`, `{role}.more.logout.cta`, `{role}.more.logout.error` | `loading`, `ready`, `error`, `logout` |
| RBAC access denied | role은 있으나 화면/action 접근이 불가함을 설명 | `rbac.access-denied.screen`, `rbac.access-denied.reason`, `rbac.access-denied.back.cta` | `forbidden`, `disabled-reason` |
| Session expired placeholder | 실제 backend 만료 응답을 붙이기 전 mock 상태 | `auth.session-expired.screen` | `unauthorized`, `retry`, `disabled-reason` |

## API Contract Boundary

`docs/20-contracts/API_CONTRACT.md`의 실패 envelope는 `UNAUTHORIZED`와 `FORBIDDEN`을 포함한다. Mobile APP은 shared API/auth client 기준으로 아래 원칙을 따른다.

- `UNAUTHORIZED`: shared auth client는 local token을 정리하고 login/session expired 후보 화면으로 연결할 수 있는 실패를 반환한다.
- `FORBIDDEN`: access denied 또는 hidden/disabled UI로 연결한다. 권한 부여 요청 flow는 구현하지 않는다.
- `role`은 backend 보유 role 중 active role이다. APP은 `/session/me?role=<role>`로 보유 role 선택을 요청할 수 있고, 미보유 role은 `FORBIDDEN`이다.
- 보유 role 후보 목록은 `/session/roles`의 `MobileSessionRoles` contract를 사용한다. local backend mode에서는 demo seed가 `emitter`, `dispatcher`, `driver`, `processor` 후보를 제공한다.
- `companyName`은 현재 backend session response에 존재한다. `companyId`, `isCompanyAdmin`, `duties[]`, multi-company active context는 [DOC-C006](../../00-governance/DOCUMENT_CONFLICT_REGISTER.md#doc-c006--session-rbac-permission-context-shape)의 contract 확장 전까지 화면 코드에서 임의 추론하지 않는다.
- APP token/session shape는 `MobileAuthSession` contract를 따른다. `accessToken`과 `refreshToken`은 SecureStore boundary에 저장하며, refresh가 `UNAUTHORIZED`면 local token을 정리한다.
- Backend access token은 30분, refresh token은 14일 TTL을 가진다. Web은 cookie, APP은 bearer header를 사용한다.
- Web 실전환 쪽은 httpOnly cookie + same-origin proxy/rewrite 선호를 기준으로 한다. APP은 이 결정에 `credentials: include`나 cookie 저장을 따라가지 않고, `X-Zerro-Platform: app` login response의 bearer token을 SecureStore에 저장한다.
- Web의 401 refresh interceptor는 APP refresh 정책을 대체하지 않는다. APP은 shared mobile auth client가 `Authorization: Bearer <refreshToken>`으로 refresh하고, 실패 시 local token을 정리한다.

## RBAC UI Rules

- route 자체가 role에 맞지 않으면 role layout에서 차단한다.
- 접근 가능한 route 안에서 workflow 상태 때문에 실행할 수 없는 action은 disabled reason을 보여준다.
- 권한이 없는 primary action은 숨김을 기본으로 한다.
- 사용자가 이미 보고 있는 화면에서 권한 문제가 드러나는 경우에는 `rbac.access-denied.*` mock surface 후보를 사용한다.
- role별 visibility 변경은 `docs/10-product/RBAC_MATRIX.md`, `apps/mobile/e2e/TEST_COVERAGE_GRAPH.md`, 관련 Maestro flow를 함께 갱신한다.

## Evidence Gate

Auth/RBAC boundary를 건드리는 PR은 변경 성격에 따라 아래를 확인한다.

| 변경 | 필수 evidence |
|---|---|
| docs-only decision | touched docs link check, local UX index placeholder 설명 갱신 |
| shared API/auth helper 변경 | `pnpm --filter @zerro/mobile test -- src/shared/api/mobile-http.test.ts src/shared/api/mobile-auth.test.ts src/shared/api/mobile-auth-mock.test.ts src/shared/api/mobile-auth-runtime.test.ts` |
| login mock session binding 변경 | `pnpm --filter @zerro/mobile e2e:maestro:login-entry`, `pnpm --filter @zerro/mobile test -- src/route-contracts/login-route-contract.test.ts` |
| existing More account/logout 변경 | `pnpm --filter @zerro/mobile e2e:maestro:auth-more-logout-backend`, `pnpm --filter @zerro/mobile e2e:maestro:role-shell-coverage`, `app-menu-session.test.ts` |
| invalid role fallback 변경 | `pnpm --filter @zerro/mobile e2e:maestro:invalid-role-fallback`, `agent-device` selector snapshot 또는 clean ADB screenshot |
| role route/visibility 변경 | `pnpm --filter @zerro/mobile e2e:maestro:role-routing`, affected role flow, `TEST_COVERAGE_GRAPH.md` 갱신 |
| Android Back/route hierarchy 변경 | `TEST_COVERAGE_GRAPH.md` back policy 갱신과 affected Maestro flow |

## Out of Scope

- Kotlin Spring backend scaffold
- 실제 backend authorization middleware
- 운영 credential 정책
- multi-company active context switching
- duties enum 완성
- OAuth/SMS/SSO/Kakao/Naver/가입/초대/비밀번호 찾기 구현
- company admin approval API 구현

## Halt Conditions

아래 중 하나가 필요해지면 화면 구현에 섞지 않고 decision work로 분리한다.

- multi-company account switching 결정
- `duties[]` enum과 role별 업무 조합 확정
- backend authorization guard를 JWT roles + DB ownership/permission guard보다 더 구체화해야 하는 경우
- 실제 `UNAUTHORIZED`/`FORBIDDEN` backend response shape 변경
- OAuth/SMS/SSO/Kakao/Naver/가입/초대/비밀번호 찾기 요구
