diff --git a/README.md b/README.md index 6718f4e..2a06eea 100644 --- a/README.md +++ b/README.md @@ -120,39 +120,22 @@ See the `docs/` directory for comprehensive documentation: ## Implementation Status -### ✅ Phase 1 Critical Features - 100% Complete +### App surfaces (aligned with feature list above) -**Security Features:** -- ✅ Screenshot & screen recording prevention -- ✅ VPN integration and enforcement -- ✅ True dual biometric authentication (PIN + Fingerprint + Facial) -- ✅ Database encryption with SQLCipher -- ✅ Hardware-backed key storage +**Security (defense-in-depth, deployment-dependent):** Screen protection (e.g. `FLAG_SECURE`), VPN hooks, MFA + biometric sign-in flow, encrypted stores — validate against your org’s assessment; not a substitute for formal certification. -**Functional Modules:** -- ✅ Directory module (complete) -- ✅ Browser module (complete) -- ✅ Communications module (framework complete) -- ✅ Meetings module (framework complete) -- ✅ Credentials, Orders, Evidence, Reports (existing) +**Functional modules — navigable UI + domain services:** +Credentials, **Orders** (list/detail, workflow steps, Room), **Evidence** (list/detail, custody transfers + chain in DB), **Reports** (type/format generation via `ReportService`), **ATF** (4473 validate/submit simulation; Forms 1/4 documented stubs), **NCIC** (ORI/UCN validation + simulated query; live III requires CJIS), **Military** (demo credential issuance), **Judicial** (demo court order), **Intelligence** (compartments, NTK, protected source demo), plus Directory, Communications, Meetings, Browser — all reachable from **Home** and the drawer under **RBAC** (`core/auth`). -**Infrastructure:** -- ✅ Offline synchronization service -- ✅ WebRTC framework -- ✅ Complete dependency injection -- ✅ Navigation framework -- ✅ Test infrastructure (27+ test cases) +**Infrastructure:** Hilt, Navigation Compose, offline/sync and backend contract (see `core/common`, `backend/`). WebRTC remains integration-ready, not a full production mesh. -### Test Coverage -- **Test Files:** 7 files -- **Test Cases:** 27+ test cases -- **Modules Tested:** 6 modules -- **Coverage:** Foundation complete +### Tests -### Code Quality -- ✅ Zero linter errors -- ✅ All dependencies configured -- ✅ Architecture patterns followed +Unit / integration tests run via `./gradlew smoaVerify` (backend + Android unit tests + debug APK). Add module-level tests as features harden; external compliance (CJIS, eTrace, barcode jurisdictions) still needs agency-specific validation. + +### Code quality + +Project builds with the configured toolchain; treat lint and coverage as ongoing hygiene. ## License diff --git a/TASKS.md b/TASKS.md index f419f24..72f72db 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,6 @@ # SMOA – Master task list -All areas; **status** reflects repo state after the 2026-02 optional-task sweep. Details: [TODO.md](./TODO.md). +All areas; **status** reflects repo state after the **2026-03** sweep (enterprise auth, OIDC, pinning, CI). Details: [TODO.md](./TODO.md). | # | Area | Task | Status | |---|------|------|--------| @@ -16,44 +16,44 @@ All areas; **status** reflects repo state after the 2026-02 optional-task sweep. | A2 | Android | InfrastructureManager STUN/signaling from BuildConfig | Done | | A3 | Android | Android 16 target doc | Done | | A4 | Android | Knox integration | **Doc:** [KNOX-INTEGRATION.md](docs/reference/KNOX-INTEGRATION.md) (SDK not bundled) | -| A5 | Android | WebRTC PeerConnection | **Stub** + [WebRTCManager](modules/communications/.../WebRTCManager.kt); optional AAR | -| A6 | Android | Connection quality | **Done:** [NetworkEstimatesConnectionQualityMonitor](modules/communications/.../NetworkEstimatesConnectionQualityMonitor.kt) | -| A7 | Android | Screen share / file transfer | **Stub** flags in [VideoTransport](modules/meetings/.../VideoTransport.kt) | -| A8 | Android | SmartCardReader | **Stub** (no PC/SC driver) | -| I1 | iOS | App in Xcode | **External** – create project; contract in [docs/ios/README.md](docs/ios/README.md) | +| A5 | Android | WebRTC PeerConnection | **Stub** — [WebRTCManager](modules/communications/src/main/java/com/smoa/modules/communications/domain/WebRTCManager.kt); optional AAR | +| A6 | Android | Connection quality | **Done** — [NetworkEstimatesConnectionQualityMonitor](modules/communications/src/main/java/com/smoa/modules/communications/domain/NetworkEstimatesConnectionQualityMonitor.kt) | +| A7 | Android | Screen share / file transfer | **Stub** — flags in [VideoTransport](modules/meetings/src/main/java/com/smoa/modules/meetings/domain/VideoTransport.kt) | +| A8 | Android | SmartCardReader | **Stub** — [SmartCardReader](core/common/src/main/java/com/smoa/core/common/SmartCardReader.kt) (no PC/SC driver) | +| I1 | iOS | App in Xcode | **External** — create project; contract in [docs/ios/README.md](docs/ios/README.md) | | I2 | iOS | Keychain / offline / biometrics / pinning | **Samples:** [docs/ios/SAMPLES.md](docs/ios/SAMPLES.md) | | W1 | Web | Scaffold + directory pull | Done | | W2 | Web | PWA manifest + service worker | Done | -| W3 | Web | Offline queue helper | Done (`offline-queue.js`) | +| W3 | Web | Offline queue helper | Done (`docs/web-scaffold/offline-queue.js`) | | W4 | Web | Deploy + CORS | **Doc:** [DEPLOY.md](docs/web-scaffold/DEPLOY.md) | | N1 | Infra | Nginx, docker-compose, k8s example | Done | | N2 | Infra | TURN/signaling self-host | **Doc:** [TURN-SIGNALING.md](docs/infrastructure/TURN-SIGNALING.md) | -| D1 | Domain | NCIC live API | **Legal gate** – CJIS; local **Room** log: [NCICQueryDatabase](modules/ncic/.../NCICQueryDatabase.kt) | -| D2 | Domain | ATF eTrace | **Legal gate**; **Room** drafts: [ATFFormDatabase](modules/atf/.../ATFFormDatabase.kt) | -| D3 | Domain | eIDAS QTSP / EU trust lists | **Product gate** – stub in [EIDASService](core/eidas/.../EIDASService.kt) | -| D4 | Domain | JCA digital signatures | **Done:** [DigitalSignatureService](core/signing/.../DigitalSignatureService.kt) | -| D5 | Domain | Electronic seal verify | **Done:** SHA-256 compare in [ElectronicSealService](core/signing/.../ElectronicSealService.kt) | +| D1 | Domain | NCIC live API | **Legal gate** — CJIS; local **Room** log: [NCICQueryDatabase](modules/ncic/src/main/java/com/smoa/modules/ncic/data/NCICQueryDatabase.kt) | +| D2 | Domain | ATF eTrace | **Legal gate**; **Room** drafts: [ATFFormDatabase](modules/atf/src/main/java/com/smoa/modules/atf/data/ATFFormDatabase.kt) | +| D3 | Domain | eIDAS QTSP / EU trust lists | **Product gate** — stub in [EIDASService](core/eidas/src/main/java/com/smoa/core/eidas/domain/EIDASService.kt) | +| D4 | Domain | JCA digital signatures | **Done:** [DigitalSignatureService](core/signing/src/main/java/com/smoa/core/signing/domain/DigitalSignatureService.kt) | +| D5 | Domain | Electronic seal verify | **Done:** SHA-256 compare in [ElectronicSealService](core/signing/src/main/java/com/smoa/core/signing/domain/ElectronicSealService.kt) | | D6 | Domain | XML/AS4 full stack | **Stub** + [core/as4/README.md](core/as4/README.md) | | D7 | Domain | Certificate revocation | Stub UNKNOWN; extend with OCSP/CRL | -| T1 | Testing | `smoaVerify` in CI | Done (`.gitea/workflows/ci.yml`) | -| T2 | Testing | Android coverage 80%+ | **Plan** – expand over time | +| T1 | Testing | `smoaVerify` in CI | Done — [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) | +| T2 | Testing | Android coverage 80%+ | **Plan** — expand over time | | T3 | Testing | E2E | **Plan:** [E2E-PLAN.md](docs/testing/E2E-PLAN.md) | | X1 | Docs | BUILD.md, TASKS.md, TODO sweep | Done | -| X2 | Build | **`./gradlew smoaVerify`** + `scripts/build-all.sh` (backend test + debug APK) | Done | +| X2 | Build | **`./gradlew smoaVerify`** (`:backend:test`, `:app:testDebugUnitTest`, `:app:assembleDebug`) + [scripts/build-all.sh](scripts/build-all.sh) | Done | | A9 | Android | TLS pinning + `AuthTokenInterceptor` + enterprise settings card | **Done** — [NetworkModule](app/src/main/java/com/smoa/di/NetworkModule.kt), [SECURITY-ENTERPRISE.md](docs/development/SECURITY-ENTERPRISE.md) | -| A10 | Android | `SecureTokenStore`, OIDC `BuildConfig`, session lock overlay | **Done** — [security/](app/src/main/java/com/smoa/security/), [SessionLockOverlay](app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt) | +| A10 | Android | `SecureTokenStore`, OIDC `BuildConfig`, session lock overlay | **Done** — [security package](app/src/main/java/com/smoa/security/), [SessionLockOverlay](app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt) | | A11 | Android | Play Integrity client + Knox classpath probe | **Done** — [PlayIntegrityVerifier](app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt), [KnoxEnterpriseProbe](app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt) | -| A12 | Android | Biometric-gated AES key scaffold | **Done** — [BiometricSecretsVault](app/src/main/java/com/smoa/security/BiometricSecretsVault.kt); **Next:** CryptoObject + token wrap | +| A12 | Android | Biometric-gated AES key (Keystore) | **Done** — [BiometricSecretsVault](app/src/main/java/com/smoa/security/BiometricSecretsVault.kt); encrypt `CryptoObject` path: **A16** | | A13 | Android | **OIDC login flow** (AppAuth) + redirect activity / intent-filter | **Done** — [OidcLoginCoordinator](app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt), manifest `RedirectUriReceiverActivity` | | A14 | Android | **Refresh token** — OkHttp `Authenticator` + `SecureTokenStore` | **Done** — [TokenRefreshAuthenticator](app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt), token endpoint persisted after OIDC | | A15 | Android | Certificate pinning host from **hosted config** (`RemoteEndpointStore`) | **Done** — [NetworkPinningConfig.resolveBackendHost](app/src/main/java/com/smoa/network/NetworkPinningConfig.kt) + [NetworkModule](app/src/main/java/com/smoa/di/NetworkModule.kt) | | A16 | Android | **BiometricPrompt.CryptoObject** + keystore encrypt path | **Done** — [BiometricSecretsVault.tryCreateEncryptCryptoObject](app/src/main/java/com/smoa/security/BiometricSecretsVault.kt), [BiometricAuthenticator.authenticateWithCryptoObject](app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt) | -| A17 | Android | Session lock: **device credential** fallback | **Done** — [BiometricAuthenticator.authenticateForSessionUnlock](app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt), sign-in MFA path allows PIN/pattern | -| A18 | Android | **Demo** auth + **SSO** when OIDC BuildConfig set | **Hybrid** — [AuthFlowHost](app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt) organization SSO + local demo; full IdP-only when you remove demo UI | -| A19 | Android | Release **R8** + pinning + Integrity + ProGuard | **Verified** — `./gradlew :app:assembleRelease` succeeds; extend release checklists per tenant | +| A17 | Android | Session lock: **device credential** fallback | **Done** — [BiometricAuthenticator.authenticateForSessionUnlock](app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt); sign-in MFA path allows PIN/pattern | +| A18 | Android | **Demo** auth + **SSO** when OIDC BuildConfig set | **Hybrid** — [AuthFlowHost](app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt) organization SSO + local demo; IdP-only when you remove demo UI | +| A19 | Android | Release **R8** + pinning + Integrity + ProGuard | **Verified** — `./gradlew :app:assembleRelease` succeeds; extend per-tenant release checklists | | A20 | Android | **Multi-host** pins | **Done** — `tls_pin_spec` in hosted config + `SMOA_TLS_PIN_SPEC` BuildConfig; [CertificatePinnerFactory.buildFromMultiSpec](app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt) | | A21 | Android | Classification / watermark from **remote config** | **Done** — `classification_watermark_*` in [SmoaClientConfigJson](app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt), [ClassificationWatermark](app/src/main/java/com/smoa/ui/components/SmoaChrome.kt) | | T4 | Testing | Unit tests: session lock logic, certificate pinning | **Done** — [SessionLockLogicTest](app/src/test/java/com/smoa/security/SessionLockLogicTest.kt), [CertificatePinnerFactoryTest](app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt); `SecureTokenStore` needs on-device / crypto-capable runner | -| T5 | Testing | UI tests: session lock, user settings | **Partial** — add `@HiltAndroidTest` + Compose rules on emulator when CI has a device | -| T6 | Testing | CI: `scripts/generate-api-docs.sh` | **Done** — [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) | +| T5 | Testing | UI tests: session lock, user settings | **Partial** — add `@HiltAndroidTest` + Compose rules when CI has an emulator/device | +| T6 | Testing | CI: `scripts/generate-api-docs.sh` | **Done** — [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) (step after `smoaVerify`) | | B9 | Backend | **Play Integrity** verify endpoint | **Stub** — `POST /api/v1/integrity/verify` returns **501** until Google API wired — [IntegrityAttestationController](backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt) | diff --git a/TODO.md b/TODO.md index e730d85..8a17193 100644 --- a/TODO.md +++ b/TODO.md @@ -1,13 +1,13 @@ -# SMOA – Task status (post-sweep) +# SMOA – Task status (living doc) -This file tracks **what the repository delivers** versus **what remains outside the repo** (legal gates, Xcode app binary, live vendor APIs). +This file tracks **what the repository delivers** versus **what remains outside the repo** (legal gates, Xcode app binary, live vendor APIs) and **follow-ups** that are intentionally incomplete. -**Master index:** [TASKS.md](./TASKS.md) -**Build:** [docs/development/BUILD.md](docs/development/BUILD.md) — run **`./gradlew smoaVerify --no-daemon`** (or `./scripts/build-all.sh`) for backend tests + debug APK. +**Master index:** [TASKS.md](./TASKS.md) +**Build:** [docs/development/BUILD.md](docs/development/BUILD.md) — run **`./gradlew smoaVerify --no-daemon`** (or `./scripts/build-all.sh`) for **backend tests**, **Android unit tests** (`:app:testDebugUnitTest`), and **debug APK** (`:app:assembleDebug`). --- -## Completed in repository (optional + remaining) +## Completed in repository ### Backend - [x] Prod profile, Flyway, PostgreSQL documentation @@ -15,15 +15,25 @@ This file tracks **what the repository delivers** versus **what remains outside - [x] Pagination / OpenAPI `@Parameter` - [x] ETag (`ShallowEtagHeaderFilter`), Request ID, API versioning doc - [x] Gradle: `pluginManagement` for `:backend`, no project-local repos under `FAIL_ON_PROJECT_REPOS` -- [x] Tests: `application-test.yml`, MockK fixes, `:backend:test` green +- [x] Tests: `application-test.yml`, `:backend:test` green +- [x] **Play Integrity API contract (stub):** `POST /api/v1/integrity/verify` returns **501** until server-side Google verification is implemented — [IntegrityAttestationController](backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt) (see [TASKS.md](TASKS.md) **B9**) ### Android -- [x] Sync/pull, Gson, BuildConfig backend URL + API key + STUN/signaling URLs +- [x] Sync/pull, Gson, BuildConfig backend URL + API key + STUN/signaling URLs + optional hosted client config (`SMOA_CONFIG_URL`) - [x] Android 16 documentation -- [x] **Connection quality:** `NetworkEstimatesConnectionQualityMonitor` (link bandwidth from `NetworkCapabilities`) +- [x] **Connection quality:** `NetworkEstimatesConnectionQualityMonitor` (bandwidth from `NetworkCapabilities`) - [x] **WebRTC / screen share / file transfer:** documented stubs; `WebRTCManager` + `VideoTransport` flags (no AAR bundled) -- [x] **SmartCardReader:** explicit no-hardware stub +- [x] **SmartCardReader:** explicit no-hardware stub — [SmartCardReader](core/common/src/main/java/com/smoa/core/common/SmartCardReader.kt) - [x] **Knox:** integration guide only — [KNOX-INTEGRATION.md](docs/reference/KNOX-INTEGRATION.md) +- [x] **TLS pinning + bearer/API key:** [NetworkModule](app/src/main/java/com/smoa/di/NetworkModule.kt), [AuthTokenInterceptor](app/src/main/java/com/smoa/network/AuthTokenInterceptor.kt), multi-host `tls_pin_spec` / `SMOA_TLS_PIN_SPEC` — [CertificatePinnerFactory](app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt) +- [x] **OIDC (AppAuth):** discovery, authorization, token exchange, `SecureTokenStore` + token endpoint persistence — [OidcLoginCoordinator](app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt), manifest `RedirectUriReceiverActivity`, Gradle `manifestPlaceholders` for redirect scheme/host +- [x] **401 refresh:** [TokenRefreshAuthenticator](app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt) + unauthenticated `OkHttpClient` +- [x] **Pinning host from hosted config:** [NetworkPinningConfig.resolveBackendHost(RemoteEndpointStore)](app/src/main/java/com/smoa/network/NetworkPinningConfig.kt) +- [x] **Session lock + device credential / biometric:** [SessionLockController](app/src/main/java/com/smoa/security/SessionLockController.kt), [SessionLockOverlay](app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt), [BiometricAuthenticator](app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt) +- [x] **Biometric Keystore + CryptoObject path:** [BiometricSecretsVault](app/src/main/java/com/smoa/security/BiometricSecretsVault.kt) +- [x] **Auth UX:** demo username/password/MFA + **Sign in with organization (SSO)** when OIDC BuildConfig is set — [AuthFlowHost](app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt), [MainActivity](app/src/main/java/com/smoa/MainActivity.kt) +- [x] **Classification watermark from remote config:** [SmoaClientConfigJson](app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt), [RemoteEndpointStore](app/src/main/java/com/smoa/config/RemoteEndpointStore.kt), [ClassificationWatermark](app/src/main/java/com/smoa/ui/components/SmoaChrome.kt) +- [x] **Play Integrity client + Knox probe:** [PlayIntegrityVerifier](app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt), [KnoxEnterpriseProbe](app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt) ### iOS - [x] API contract + checklist + **Swift samples** — [docs/ios/README.md](docs/ios/README.md), [docs/ios/SAMPLES.md](docs/ios/SAMPLES.md) @@ -49,27 +59,28 @@ This file tracks **what the repository delivers** versus **what remains outside ### Testing & CI - [x] Gitea Actions: `./gradlew smoaVerify --no-daemon` — [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) +- [x] **`bash scripts/generate-api-docs.sh`** in CI (static ReDoc under `docs/api/generated/`) — same workflow - [x] E2E plan doc — [docs/testing/E2E-PLAN.md](docs/testing/E2E-PLAN.md) -- [ ] Android 80%+ coverage — ongoing goal - -### Enterprise / auth / networking (coding follow-ups) - -See [TASKS.md](./TASKS.md) rows **A9–A21**, **T4–T6**. Summary: - -- [ ] **OIDC client:** AppAuth or SSO WebView; redirect `intent-filter`; code exchange → `SecureTokenStore.persistTokens` -- [ ] **Token refresh:** OkHttp `Authenticator` on 401 using `refresh_token` -- [ ] **Pinning + dynamic URL:** resolve pin host from `RemoteEndpointStore` when backend URL comes only from hosted config -- [ ] **Biometric + Keystore:** `Cipher` + `BiometricPrompt.CryptoObject` to wrap/unwrap refresh token with `BiometricSecretsVault` -- [ ] **Session lock:** optional device-credential path (not only biometrics) where policy allows -- [ ] **Play Integrity:** POST token to your backend; add **server** verification (Play Integrity API) — sample in backend -- [ ] **Tests:** `SessionLockController`, pinning factory, `SecureTokenStore`, `navigateSmoa`, settings ViewModel -- [ ] **Auth UX:** replace demo password/MFA in `AuthFlowHost` with IdP-driven flow or hybrid step-up -- [ ] **Release:** R8/proguard shrink test with Integrity + OkHttp pinning enabled -- [ ] **CI:** optional `generate-api-docs.sh` + publish `docs/api/generated` -- [ ] **Multi-pin hosts:** if pull/config use different origins than `SMOA_BACKEND_BASE_URL`, extend `CertificatePinnerFactory` +- [x] Unit tests: session lock policy helper, certificate pinner factory — [SessionLockLogicTest](app/src/test/java/com/smoa/security/SessionLockLogicTest.kt), [CertificatePinnerFactoryTest](app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt) +- [ ] Android **80%+ coverage** — ongoing goal +- [ ] **Instrumented / on-device tests** for `SecureTokenStore` and other Android Keystore paths (not reliable on JVM Robolectric) +- [ ] **UI tests** (session lock overlay, user settings enterprise card) when CI has an emulator — see [TASKS.md](TASKS.md) **T5** ### Documentation -- [x] Backend README, IMPLEMENTATION_STATUS “next steps”, BUILD.md, TASKS.md +- [x] Backend README, [IMPLEMENTATION_STATUS](docs/status/IMPLEMENTATION_STATUS.md) (historical snapshot; see TASKS for current feature rows), [BUILD.md](docs/development/BUILD.md), TASKS.md, this file + +--- + +## Follow-ups (in repo, not “done”) + +| Item | Notes | +|------|--------| +| **B9 production** | Implement Google Play Integrity **DecryptIntegrityToken** + nonce lifecycle on server; client already posts contract via stub endpoint | +| **A18 IdP-only** | Remove or gate demo password/MFA in `AuthFlowHost` when all users must use SSO | +| **Refresh token + CryptoObject** | Optional: wrap plaintext refresh in keystore-backed ciphertext after OIDC; wire unwrap into refresh flow only if product policy requires it | +| **Dynamic OkHttp rebuild** | Hosted config can change API URL at runtime; pinning host is fixed at process start unless you add client refresh (see [NetworkModule](app/src/main/java/com/smoa/di/NetworkModule.kt)) | +| **Publish API docs** | CI generates files locally; optional: upload `docs/api/generated` to static hosting / Pages | +| **D7 revocation** | Replace UNKNOWN stub with OCSP/CRL where policies require | --- @@ -91,11 +102,11 @@ See [TASKS.md](./TASKS.md) rows **A9–A21**, **T4–T6**. Summary: | Area | Repo-delivered | External / binary app | |------|----------------|------------------------| -| Backend | All listed | — | -| Android | All listed except Knox binary | Knox AAR, WebRTC AAR optional | +| Backend | Core API + tests + integrity **stub** | Full Play Integrity verification service | +| Android | Modules + enterprise auth/networking above | Knox AAR, WebRTC AAR optional | | iOS | Docs + samples | Xcode application | | Web | Scaffold + PWA + deploy doc | Your HTTPS host + CORS env | | Infra | Examples + docs | Your servers | | Domain | Room + JCA + stubs | Live agency/vendor APIs | -Use [TASKS.md](./TASKS.md) for the flat checklist with file pointers. +Use [TASKS.md](./TASKS.md) for the flat checklist with stable row IDs (**A1–A21**, **B1–B9**, **T1–T6**, etc.). diff --git a/app/src/main/java/com/smoa/ui/main/HomeScreen.kt b/app/src/main/java/com/smoa/ui/main/HomeScreen.kt index 42fd407..4217748 100644 --- a/app/src/main/java/com/smoa/ui/main/HomeScreen.kt +++ b/app/src/main/java/com/smoa/ui/main/HomeScreen.kt @@ -16,12 +16,17 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Assignment import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.Policy +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -50,7 +55,7 @@ import com.smoa.ui.theme.SmoaSpatial private data class HomeRoute( val labelRes: Int, val route: String, - val module: RBACFramework.Module?, + val module: RBACFramework.Module, val icon: ImageVector ) @@ -72,10 +77,15 @@ fun HomeScreen( val allRoutes = remember { listOf( - HomeRoute(R.string.module_credentials, SMOARoute.Credentials.route, null, Icons.Default.Info), - HomeRoute(R.string.home_orders, SMOARoute.Orders.route, null, Icons.Default.Description), - HomeRoute(R.string.home_evidence, SMOARoute.Evidence.route, null, Icons.Default.Folder), - HomeRoute(R.string.home_reports, SMOARoute.Reports.route, null, Icons.Default.Description), + HomeRoute(R.string.module_credentials, SMOARoute.Credentials.route, RBACFramework.Module.CREDENTIALS, Icons.Default.Info), + HomeRoute(R.string.home_orders, SMOARoute.Orders.route, RBACFramework.Module.ORDERS, Icons.Default.Description), + HomeRoute(R.string.home_evidence, SMOARoute.Evidence.route, RBACFramework.Module.EVIDENCE, Icons.Default.Folder), + HomeRoute(R.string.home_reports, SMOARoute.Reports.route, RBACFramework.Module.REPORTS, Icons.Default.Description), + HomeRoute(R.string.module_atf, SMOARoute.Atf.route, RBACFramework.Module.ATF, Icons.Default.Assignment), + HomeRoute(R.string.module_ncic, SMOARoute.Ncic.route, RBACFramework.Module.NCIC, Icons.Default.Search), + HomeRoute(R.string.module_military, SMOARoute.Military.route, RBACFramework.Module.MILITARY, Icons.Default.Security), + HomeRoute(R.string.module_judicial, SMOARoute.Judicial.route, RBACFramework.Module.JUDICIAL, Icons.Default.Gavel), + HomeRoute(R.string.module_intelligence, SMOARoute.Intelligence.route, RBACFramework.Module.INTELLIGENCE, Icons.Default.Policy), HomeRoute(R.string.module_directory, SMOARoute.Directory.route, RBACFramework.Module.DIRECTORY, Icons.Default.Person), HomeRoute(R.string.module_communications, SMOARoute.Communications.route, RBACFramework.Module.COMMUNICATIONS, Icons.Default.Phone), HomeRoute(R.string.module_meetings, SMOARoute.Meetings.route, RBACFramework.Module.MEETINGS, Icons.Default.Phone), @@ -84,7 +94,7 @@ fun HomeScreen( } val tiles = remember(userRole, rbacFramework) { allRoutes.filter { route -> - route.module == null || rbacFramework.canAccessModule(userRole, route.module) + rbacFramework.canAccessModule(userRole, route.module) } } diff --git a/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt b/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt index 843e858..d476b35 100644 --- a/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt +++ b/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt @@ -1,13 +1,22 @@ package com.smoa.ui.navigation -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Phone import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.* +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -34,6 +43,11 @@ fun NavigationDrawer( val currentUser by userSession.currentUser.collectAsState() val userRole = currentUser?.role ?: RBACFramework.Role.GUEST + fun go(route: String) { + navController.navigateSmoa(route) + onDrawerDismiss() + } + ModalDrawerSheet( modifier = modifier.width(280.dp) ) { @@ -42,16 +56,15 @@ fun NavigationDrawer( .fillMaxHeight() .padding(16.dp) ) { - // User info header Text( text = currentUser?.userName ?: "Guest", - style = MaterialTheme.typography.headlineSmall, + style = androidx.compose.material3.MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 8.dp) ) Text( text = "Role: ${userRole.name}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 24.dp) ) @@ -60,61 +73,88 @@ fun NavigationDrawer( NavigationDrawerItem( label = { Text(stringResource(R.string.home_title)) }, selected = navController.currentDestination?.route == SMOARoute.Home.route, - onClick = { - navController.navigateSmoa(SMOARoute.Home.route) - onDrawerDismiss() - }, + onClick = { go(SMOARoute.Home.route) }, icon = { Icon(Icons.Default.Home, contentDescription = null) } ) - NavigationDrawerItem( - label = { Text(stringResource(R.string.module_credentials)) }, - selected = navController.currentDestination?.route == SMOARoute.Credentials.route, - onClick = { - navController.navigateSmoa(SMOARoute.Credentials.route) - onDrawerDismiss() - }, - icon = { Icon(Icons.Default.Info, contentDescription = null) } - ) - - NavigationDrawerItem( - label = { Text(stringResource(R.string.home_orders)) }, - selected = navController.currentDestination?.route == SMOARoute.Orders.route, - onClick = { - navController.navigateSmoa(SMOARoute.Orders.route) - onDrawerDismiss() - }, - icon = { Icon(Icons.Default.Info, contentDescription = null) } - ) - - NavigationDrawerItem( - label = { Text(stringResource(R.string.home_evidence)) }, - selected = navController.currentDestination?.route == SMOARoute.Evidence.route, - onClick = { - navController.navigateSmoa(SMOARoute.Evidence.route) - onDrawerDismiss() - }, - icon = { Icon(Icons.Default.Info, contentDescription = null) } - ) - - NavigationDrawerItem( - label = { Text(stringResource(R.string.home_reports)) }, - selected = navController.currentDestination?.route == SMOARoute.Reports.route, - onClick = { - navController.navigateSmoa(SMOARoute.Reports.route) - onDrawerDismiss() - }, - icon = { Icon(Icons.Default.Info, contentDescription = null) } - ) + if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.CREDENTIALS)) { + NavigationDrawerItem( + label = { Text(stringResource(R.string.module_credentials)) }, + selected = navController.currentDestination?.route == SMOARoute.Credentials.route, + onClick = { go(SMOARoute.Credentials.route) }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } + if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.ORDERS)) { + NavigationDrawerItem( + label = { Text(stringResource(R.string.home_orders)) }, + selected = navController.currentDestination?.route == SMOARoute.Orders.route, + onClick = { go(SMOARoute.Orders.route) }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } + if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.EVIDENCE)) { + NavigationDrawerItem( + label = { Text(stringResource(R.string.home_evidence)) }, + selected = navController.currentDestination?.route == SMOARoute.Evidence.route, + onClick = { go(SMOARoute.Evidence.route) }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } + if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.REPORTS)) { + NavigationDrawerItem( + label = { Text(stringResource(R.string.home_reports)) }, + selected = navController.currentDestination?.route == SMOARoute.Reports.route, + onClick = { go(SMOARoute.Reports.route) }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } + if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.ATF)) { + NavigationDrawerItem( + label = { Text(stringResource(R.string.module_atf)) }, + selected = navController.currentDestination?.route == SMOARoute.Atf.route, + onClick = { go(SMOARoute.Atf.route) }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } + if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.NCIC)) { + NavigationDrawerItem( + label = { Text(stringResource(R.string.module_ncic)) }, + selected = navController.currentDestination?.route == SMOARoute.Ncic.route, + onClick = { go(SMOARoute.Ncic.route) }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } + if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.MILITARY)) { + NavigationDrawerItem( + label = { Text(stringResource(R.string.module_military)) }, + selected = navController.currentDestination?.route == SMOARoute.Military.route, + onClick = { go(SMOARoute.Military.route) }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } + if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.JUDICIAL)) { + NavigationDrawerItem( + label = { Text(stringResource(R.string.module_judicial)) }, + selected = navController.currentDestination?.route == SMOARoute.Judicial.route, + onClick = { go(SMOARoute.Judicial.route) }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } + if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.INTELLIGENCE)) { + NavigationDrawerItem( + label = { Text(stringResource(R.string.module_intelligence)) }, + selected = navController.currentDestination?.route == SMOARoute.Intelligence.route, + onClick = { go(SMOARoute.Intelligence.route) }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.DIRECTORY)) { NavigationDrawerItem( label = { Text(stringResource(R.string.module_directory)) }, selected = navController.currentDestination?.route == SMOARoute.Directory.route, - onClick = { - navController.navigateSmoa(SMOARoute.Directory.route) - onDrawerDismiss() - }, + onClick = { go(SMOARoute.Directory.route) }, icon = { Icon(Icons.Default.Person, contentDescription = null) } ) } @@ -123,10 +163,7 @@ fun NavigationDrawer( NavigationDrawerItem( label = { Text(stringResource(R.string.module_communications)) }, selected = navController.currentDestination?.route == SMOARoute.Communications.route, - onClick = { - navController.navigateSmoa(SMOARoute.Communications.route) - onDrawerDismiss() - }, + onClick = { go(SMOARoute.Communications.route) }, icon = { Icon(Icons.Default.Phone, contentDescription = null) } ) } @@ -135,10 +172,7 @@ fun NavigationDrawer( NavigationDrawerItem( label = { Text(stringResource(R.string.module_meetings)) }, selected = navController.currentDestination?.route == SMOARoute.Meetings.route, - onClick = { - navController.navigateSmoa(SMOARoute.Meetings.route) - onDrawerDismiss() - }, + onClick = { go(SMOARoute.Meetings.route) }, icon = { Icon(Icons.Default.Phone, contentDescription = null) } ) } @@ -147,10 +181,7 @@ fun NavigationDrawer( NavigationDrawerItem( label = { Text(stringResource(R.string.module_browser)) }, selected = navController.currentDestination?.route == SMOARoute.Browser.route, - onClick = { - navController.navigateSmoa(SMOARoute.Browser.route) - onDrawerDismiss() - }, + onClick = { go(SMOARoute.Browser.route) }, icon = { Icon(Icons.Default.Info, contentDescription = null) } ) } @@ -161,13 +192,9 @@ fun NavigationDrawer( NavigationDrawerItem( label = { Text(stringResource(R.string.user_settings_title)) }, selected = navController.currentDestination?.route == SMOARoute.UserSettings.route, - onClick = { - navController.navigateSmoa(SMOARoute.UserSettings.route) - onDrawerDismiss() - }, + onClick = { go(SMOARoute.UserSettings.route) }, icon = { Icon(Icons.Default.Settings, contentDescription = null) } ) } } } - diff --git a/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt b/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt index a2fef57..7a47795 100644 --- a/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt +++ b/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt @@ -9,8 +9,10 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.smoa.core.auth.RBACFramework import com.smoa.core.auth.UserSession -import com.smoa.ui.main.HomeScreen -import com.smoa.ui.settings.UserSettingsScreen +import com.smoa.core.common.ConnectivityManager +import com.smoa.core.common.FoldableStateManager +import com.smoa.core.security.ScreenProtection +import com.smoa.modules.atf.ATFModule import com.smoa.modules.browser.BrowserModule import com.smoa.modules.browser.domain.BrowserService import com.smoa.modules.browser.domain.URLFilter @@ -19,14 +21,17 @@ import com.smoa.modules.communications.domain.CommunicationsService import com.smoa.modules.credentials.CredentialsModule import com.smoa.modules.directory.DirectoryModule import com.smoa.modules.directory.domain.DirectoryService +import com.smoa.modules.evidence.ui.EvidenceModule +import com.smoa.modules.intelligence.IntelligenceModule +import com.smoa.modules.judicial.JudicialModule import com.smoa.modules.meetings.MeetingsModule import com.smoa.modules.meetings.domain.MeetingsService +import com.smoa.modules.military.MilitaryModule +import com.smoa.modules.ncic.NCICModule import com.smoa.modules.orders.ui.OrdersModule -import com.smoa.modules.evidence.ui.EvidenceModule import com.smoa.modules.reports.ui.ReportGenerationScreen -import com.smoa.core.common.ConnectivityManager -import com.smoa.core.common.FoldableStateManager -import com.smoa.core.security.ScreenProtection +import com.smoa.ui.main.HomeScreen +import com.smoa.ui.settings.UserSettingsScreen /** * Navigation routes for SMOA modules. @@ -41,6 +46,11 @@ sealed class SMOARoute(val route: String) { object Orders : SMOARoute("orders") object Evidence : SMOARoute("evidence") object Reports : SMOARoute("reports") + object Atf : SMOARoute("atf") + object Ncic : SMOARoute("ncic") + object Military : SMOARoute("military") + object Judicial : SMOARoute("judicial") + object Intelligence : SMOARoute("intelligence") object UserSettings : SMOARoute("user_settings") } @@ -99,15 +109,35 @@ fun SMOANavigation( } composable(SMOARoute.Orders.route) { - OrdersModule(modifier = Modifier) + OrdersModule(userId = userId, modifier = Modifier) } composable(SMOARoute.Evidence.route) { - EvidenceModule(modifier = Modifier) + EvidenceModule(userId = userId, modifier = Modifier) } composable(SMOARoute.Reports.route) { - ReportGenerationScreen(modifier = Modifier) + ReportGenerationScreen(userId = userId, modifier = Modifier) + } + + composable(SMOARoute.Atf.route) { + ATFModule(modifier = Modifier) + } + + composable(SMOARoute.Ncic.route) { + NCICModule(userId = userId, modifier = Modifier) + } + + composable(SMOARoute.Military.route) { + MilitaryModule(modifier = Modifier) + } + + composable(SMOARoute.Judicial.route) { + JudicialModule(modifier = Modifier) + } + + composable(SMOARoute.Intelligence.route) { + IntelligenceModule(userId = userId, modifier = Modifier) } composable(SMOARoute.Directory.route) { @@ -147,4 +177,3 @@ fun SMOANavigation( } } } - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75822ed..af75de5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,6 +70,11 @@ Unit Communications Secure Meetings Controlled Browser + ATF forms + NCIC / III + Military operations + Judicial + Intelligence (MLS) OFFICIAL USE ONLY diff --git a/backend/README.md b/backend/README.md index 770f739..99388e8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -128,6 +128,8 @@ Request DTOs align with the app’s directory, order, evidence, report, and cred Credential sync types and mobile barcode templates are aligned with the **Complete Credential** shared schema (`CredentialRef.domain`) and SMOA formats (ICAO 9303, AAMVA, MIL-STD-129). See [../docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md](../docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md) and backend `SmoaCredentialType`. +**Umbrella program:** integration boundary and runbook — `complete-credential/docs/integrations/smoa.md` and ADR-0010 (`adr/0010-smoa-mobile-integration-boundary.md`) when the [complete-credential](https://gitea.d-bis.org/DBIS/complete-credential) repo is available. Drift check: `SMOA_REPO` + `tools/verify/verify-smoa-cc-domain-alignment.sh` from that repo root. + ## Gap analysis and roadmap See [docs/BACKEND-GAPS-AND-ROADMAP.md](docs/BACKEND-GAPS-AND-ROADMAP.md) for a full review: what's covered, completed gaps (delete sync, pull/GET, enum validation, rate limiting, audit, tests, Dockerfile), and optional follow-ups (prod profile, unit/tenant scoping, migrations). diff --git a/backend/docs/BACKEND-GAPS-AND-ROADMAP.md b/backend/docs/BACKEND-GAPS-AND-ROADMAP.md index f29b4ec..9742f8f 100644 --- a/backend/docs/BACKEND-GAPS-AND-ROADMAP.md +++ b/backend/docs/BACKEND-GAPS-AND-ROADMAP.md @@ -68,7 +68,8 @@ The backend implements the **sync contract** expected by the mobile app (POST sy ### 9. **Ids and authorization** - **Gap:** No tenant/org/unit scoping; any client with a valid API key can read/write any resource. -- **Recommendation:** If the app is multi-tenant or unit-scoped, add unit/tenant to API key or token and filter queries (e.g. directory by unit, orders by unit). +- **Incremental:** When `X-Unit` or `unit` is present, [TenantFilter](../src/main/kotlin/com/smoa/backend/config/TenantFilter.kt) sets request attribute `com.smoa.tenant.unit` for downstream use. Optional `smoa.tenant.require-unit=true` still only **requires** the header; it does not filter JPA rows. +- **Recommendation:** If the app is multi-tenant or unit-scoped, add unit/tenant to API key or token and filter queries (e.g. directory by unit, orders by unit), or PostgreSQL RLS — see `docs/security/TENANT-THREAT-MODEL.md`. ### 10. **Infrastructure** ✅ Done (Dockerfile) diff --git a/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt b/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt index a911de8..d91aebd 100644 --- a/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt +++ b/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt @@ -11,6 +11,10 @@ import org.springframework.web.filter.OncePerRequestFilter /** * When smoa.tenant.require-unit is true, requires X-Unit header for /api/v1/sync and other /api/v1 pull routes. * Returns 400 if unit is required but missing. + * + * When a unit is present (header or query), it is stored as a request attribute [TENANT_UNIT_REQUEST_ATTR] + * so controllers or future repository filters can scope data without parsing headers again. + * This does **not** enforce row-level security; see `docs/security/TENANT-THREAT-MODEL.md`. */ @Component @Order(2) @@ -24,11 +28,18 @@ class TenantFilter : OncePerRequestFilter() { response: HttpServletResponse, filterChain: FilterChain ) { - if (!requireUnit || !request.requestURI.startsWith("/api/v1")) { + if (!request.requestURI.startsWith("/api/v1")) { filterChain.doFilter(request, response) return } val unit = request.getHeader("X-Unit") ?: request.getParameter("unit") + if (!unit.isNullOrBlank()) { + request.setAttribute(TENANT_UNIT_REQUEST_ATTR, unit.trim()) + } + if (!requireUnit) { + filterChain.doFilter(request, response) + return + } if (unit.isNullOrBlank()) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "application/json" @@ -37,4 +48,8 @@ class TenantFilter : OncePerRequestFilter() { } filterChain.doFilter(request, response) } + + companion object { + const val TENANT_UNIT_REQUEST_ATTR = "com.smoa.tenant.unit" + } } diff --git a/backend/src/test/kotlin/com/smoa/backend/api/OpenApiContractIntegrationTest.kt b/backend/src/test/kotlin/com/smoa/backend/api/OpenApiContractIntegrationTest.kt new file mode 100644 index 0000000..37cd607 --- /dev/null +++ b/backend/src/test/kotlin/com/smoa/backend/api/OpenApiContractIntegrationTest.kt @@ -0,0 +1,36 @@ +package com.smoa.backend.api + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +/** + * Lightweight contract check: springdoc OpenAPI exposes expected v1 paths. + * Complements human-maintained [docs/api/api-specification.yaml] (see OPENAPI-SYNCHRONIZATION.md). + */ +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class OpenApiContractIntegrationTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Test + fun `GET v3 api-docs is ok and lists core pull and sync paths`() { + mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.openapi").exists()) + .andExpect(jsonPath("$.paths.['/api/v1/directory']").exists()) + .andExpect(jsonPath("$.paths.['/api/v1/credentials']").exists()) + .andExpect(jsonPath("$.paths.['/api/v1/credentials/{credentialId}']").exists()) + .andExpect(jsonPath("$.paths.['/api/v1/sync/credential']").exists()) + .andExpect(jsonPath("$.paths.['/api/v1/integrity/verify']").exists()) + } +} diff --git a/core/auth/src/main/java/com/smoa/core/auth/RBACFramework.kt b/core/auth/src/main/java/com/smoa/core/auth/RBACFramework.kt index e53c990..c9272d3 100644 --- a/core/auth/src/main/java/com/smoa/core/auth/RBACFramework.kt +++ b/core/auth/src/main/java/com/smoa/core/auth/RBACFramework.kt @@ -9,10 +9,7 @@ import javax.inject.Singleton */ @Singleton class RBACFramework @Inject constructor() { - - /** - * User role definitions. - */ + enum class Role { ADMIN, OPERATOR, @@ -20,65 +17,69 @@ class RBACFramework @Inject constructor() { GUEST } - /** - * Permission definitions for modules and features. - */ enum class Permission { - // Credentials module VIEW_CREDENTIALS, DISPLAY_CREDENTIALS, - - // Directory module + VIEW_DIRECTORY, SEARCH_DIRECTORY, VIEW_UNIT_DIRECTORY, - - // Communications module + USE_RADIO, JOIN_CHANNEL, CREATE_CHANNEL, - - // Meetings module + JOIN_MEETING, HOST_MEETING, SCREEN_SHARE, - - // Browser module + ACCESS_BROWSER, - NAVIGATE_URL + NAVIGATE_URL, + + VIEW_ORDERS, + MANAGE_ORDERS, + + VIEW_EVIDENCE, + MANAGE_EVIDENCE, + + VIEW_REPORTS, + GENERATE_REPORTS, + + USE_ATF, + USE_NCIC, + USE_MILITARY, + USE_JUDICIAL, + USE_INTELLIGENCE } - /** - * Module access definitions. - */ enum class Module { CREDENTIALS, DIRECTORY, COMMUNICATIONS, MEETINGS, - BROWSER + BROWSER, + ORDERS, + EVIDENCE, + REPORTS, + ATF, + NCIC, + MILITARY, + JUDICIAL, + INTELLIGENCE } - /** - * Check if a role has a specific permission. - */ fun hasPermission(role: Role, permission: Permission): Boolean { return getPermissionsForRole(role).contains(permission) } - /** - * Check if a role can access a module. - */ fun canAccessModule(role: Role, module: Module): Boolean { return getModulesForRole(role).contains(module) } - /** - * Get all permissions for a role. - */ private fun getPermissionsForRole(role: Role): Set { return when (role) { - Role.ADMIN -> setOf( + Role.ADMIN -> Permission.entries.toSet() + Role.OPERATOR -> setOf( Permission.VIEW_CREDENTIALS, Permission.DISPLAY_CREDENTIALS, Permission.VIEW_DIRECTORY, @@ -91,60 +92,68 @@ class RBACFramework @Inject constructor() { Permission.HOST_MEETING, Permission.SCREEN_SHARE, Permission.ACCESS_BROWSER, - Permission.NAVIGATE_URL - ) - Role.OPERATOR -> setOf( - Permission.VIEW_CREDENTIALS, - Permission.DISPLAY_CREDENTIALS, - Permission.VIEW_DIRECTORY, - Permission.SEARCH_DIRECTORY, - Permission.VIEW_UNIT_DIRECTORY, - Permission.USE_RADIO, - Permission.JOIN_CHANNEL, - Permission.JOIN_MEETING, - Permission.SCREEN_SHARE, - Permission.ACCESS_BROWSER + Permission.NAVIGATE_URL, + Permission.VIEW_ORDERS, + Permission.MANAGE_ORDERS, + Permission.VIEW_EVIDENCE, + Permission.MANAGE_EVIDENCE, + Permission.VIEW_REPORTS, + Permission.GENERATE_REPORTS, + Permission.USE_ATF, + Permission.USE_NCIC, + Permission.USE_MILITARY, + Permission.USE_JUDICIAL, + Permission.USE_INTELLIGENCE ) Role.VIEWER -> setOf( Permission.VIEW_CREDENTIALS, Permission.VIEW_DIRECTORY, Permission.SEARCH_DIRECTORY, - Permission.JOIN_MEETING + Permission.JOIN_MEETING, + Permission.VIEW_ORDERS, + Permission.VIEW_EVIDENCE, + Permission.VIEW_REPORTS, + Permission.USE_NCIC, + Permission.USE_MILITARY, + Permission.USE_JUDICIAL ) Role.GUEST -> setOf( - Permission.VIEW_CREDENTIALS + Permission.VIEW_CREDENTIALS, + Permission.VIEW_ORDERS ) } } - /** - * Get all modules accessible by a role. - */ private fun getModulesForRole(role: Role): Set { return when (role) { - Role.ADMIN -> setOf( - Module.CREDENTIALS, - Module.DIRECTORY, - Module.COMMUNICATIONS, - Module.MEETINGS, - Module.BROWSER - ) + Role.ADMIN -> Module.entries.toSet() Role.OPERATOR -> setOf( Module.CREDENTIALS, Module.DIRECTORY, Module.COMMUNICATIONS, Module.MEETINGS, - Module.BROWSER + Module.BROWSER, + Module.ORDERS, + Module.EVIDENCE, + Module.REPORTS, + Module.ATF, + Module.NCIC, + Module.MILITARY, + Module.JUDICIAL, + Module.INTELLIGENCE ) Role.VIEWER -> setOf( Module.CREDENTIALS, Module.DIRECTORY, - Module.MEETINGS - ) - Role.GUEST -> setOf( - Module.CREDENTIALS + Module.MEETINGS, + Module.ORDERS, + Module.EVIDENCE, + Module.REPORTS, + Module.NCIC, + Module.MILITARY, + Module.JUDICIAL ) + Role.GUEST -> setOf(Module.CREDENTIALS, Module.ORDERS) } } } - diff --git a/core/certificates/build.gradle.kts b/core/certificates/build.gradle.kts index 1f42c27..4eee43e 100644 --- a/core/certificates/build.gradle.kts +++ b/core/certificates/build.gradle.kts @@ -49,4 +49,5 @@ dependencies { // Testing testImplementation(Dependencies.junit) + testImplementation(Dependencies.mockWebServer) } diff --git a/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt b/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt index c65c692..045387d 100644 --- a/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt +++ b/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt @@ -1,5 +1,8 @@ package com.smoa.core.certificates.domain +import java.net.HttpURLConnection +import java.net.URL +import java.security.cert.CertificateFactory import java.security.cert.X509CRL import java.security.cert.X509Certificate import java.util.Date @@ -7,6 +10,14 @@ import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.bouncycastle.asn1.DERIA5String +import org.bouncycastle.asn1.x509.CRLDistPoint +import org.bouncycastle.asn1.x509.DistributionPoint +import org.bouncycastle.asn1.x509.DistributionPointName +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNames +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder /** * Certificate management system. @@ -62,10 +73,77 @@ class CertificateManager @Inject constructor() { } } + /** + * Parse CRL Distribution Points from the certificate (HTTP/HTTPS URIs only). + */ + fun extractCrlDistributionPointHttpUrls(certificate: X509Certificate): List { + return try { + val holder = JcaX509CertificateHolder(certificate) + val ext = holder.getExtension(Extension.cRLDistributionPoints) ?: return emptyList() + val crlDp = CRLDistPoint.getInstance(ext.parsedValue) + val urls = mutableListOf() + for (dp in crlDp.distributionPoints) { + collectHttpUrlsFromDistributionPoint(dp, urls) + } + urls.distinct() + } catch (_: Exception) { + emptyList() + } + } + + private fun collectHttpUrlsFromDistributionPoint(dp: DistributionPoint, out: MutableList) { + val distPointName = dp.distributionPoint ?: return + if (distPointName.type != DistributionPointName.FULL_NAME) return + val generalNames = GeneralNames.getInstance(distPointName.name) + for (gn in generalNames.names) { + if (gn.tagNo != GeneralName.uniformResourceIdentifier) continue + val uri = DERIA5String.getInstance(gn.name).string + if (uri.startsWith("http://", ignoreCase = true) || uri.startsWith("https://", ignoreCase = true)) { + out.add(uri) + } + } + } + + /** + * Download a CRL from an HTTP(S) URL and store it for [checkRevocationStatus]. + */ + suspend fun fetchAndStoreCrlFromHttpUrl(urlString: String): Boolean { + return withContext(Dispatchers.IO) { + try { + val url = URL(urlString) + val protocol = url.protocol.lowercase() + if (protocol != "http" && protocol != "https") return@withContext false + val conn = url.openConnection() as HttpURLConnection + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.requestMethod = "GET" + conn.instanceFollowRedirects = true + conn.inputStream.use { ins -> + val crl = CertificateFactory.getInstance("X.509").generateCRL(ins) as X509CRL + storeCrl(crl) + } + true + } catch (_: Exception) { + false + } + } + } + + /** + * Fetches the first reachable CRL listed in the certificate CDP extension. + */ + suspend fun fetchAndStoreCrlFromCertificateDistributionPoints(certificate: X509Certificate): Boolean { + for (u in extractCrlDistributionPointHttpUrls(certificate)) { + if (fetchAndStoreCrlFromHttpUrl(u)) return true + } + return false + } + /** * Check certificate revocation status via OCSP/CRL. * Best-effort implementation: * - Uses locally stored CRLs for deterministic results. + * - Optional: call [fetchAndStoreCrlFromCertificateDistributionPoints] first for online CRL. * - Treats self-signed certificates as VALID when no CRL is available. * - Returns UNKNOWN when issuer CRL is not present. */ diff --git a/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt b/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt index 3b217fd..4a962b0 100644 --- a/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt +++ b/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt @@ -1,17 +1,19 @@ package com.smoa.core.certificates.domain -import org.bouncycastle.asn1.x500.X500Name import org.bouncycastle.cert.jcajce.JcaX509CRLConverter import org.bouncycastle.cert.jcajce.JcaX509v2CRLBuilder import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test import java.security.Security import java.util.Date +import javax.security.auth.x500.X500Principal class CertificateManagerTest { @Test @@ -44,13 +46,13 @@ class CertificateManagerTest { } @Test - fun `checkRevocationStatus returns unknown without revocation data`() = runBlocking { + fun `checkRevocationStatus returns valid for self-signed when no CRL loaded`() = runBlocking { val (_, certificate) = TestCertificates.generateSelfSignedCertificate() val manager = CertificateManager() val status = manager.checkRevocationStatus(certificate) - assertEquals(RevocationStatus.UNKNOWN, status) + assertEquals(RevocationStatus.VALID, status) } @Test @@ -64,6 +66,39 @@ class CertificateManagerTest { assertEquals(RevocationStatus.VALID, status) } + @Test + fun `extractCrlDistributionPointHttpUrls returns empty without CDP extension`() { + val (_, certificate) = TestCertificates.generateSelfSignedCertificate() + val manager = CertificateManager() + + val urls = manager.extractCrlDistributionPointHttpUrls(certificate) + + assertTrue(urls.isEmpty()) + } + + @Test + fun `fetchAndStoreCrlFromHttpUrl stores CRL from server`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val crl = buildCrl(keyPair.private, certificate.subjectX500Principal.name, emptyList()) + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/pkix-crl") + .setBody(okio.Buffer().write(crl.encoded)) + ) + server.start() + try { + val manager = CertificateManager() + val url = server.url("/test.crl").toString() + assertTrue(manager.fetchAndStoreCrlFromHttpUrl(url)) + val status = manager.checkRevocationStatus(certificate) + assertEquals(RevocationStatus.VALID, status) + } finally { + server.shutdown() + } + } + @Test fun `checkRevocationStatus returns revoked when certificate is listed in crl`() = runBlocking { val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() @@ -83,7 +118,7 @@ class CertificateManagerTest { val provider = BouncyCastleProvider() Security.addProvider(provider) val now = Date() - val builder = JcaX509v2CRLBuilder(X500Name(issuerDn), now) + val builder = JcaX509v2CRLBuilder(X500Principal(issuerDn), now) builder.setNextUpdate(Date(now.time + 24L * 60 * 60 * 1000L)) revokedSerials.forEach { serial -> builder.addCRLEntry(serial, now, 0) diff --git a/core/common/src/main/java/com/smoa/core/common/SyncService.kt b/core/common/src/main/java/com/smoa/core/common/SyncService.kt index 838ea34..8807462 100644 --- a/core/common/src/main/java/com/smoa/core/common/SyncService.kt +++ b/core/common/src/main/java/com/smoa/core/common/SyncService.kt @@ -73,6 +73,11 @@ class SyncService @Inject constructor( if (r is Result.Success) { results.add(PullResultData("credentials", r.data)) runCatching { credentialCacheSyncPort.mergeFromPullCredentialsJson(r.data) } + runCatching { + credentialCacheSyncPort.hydrateMissingPayloads { id -> + pullAPI.pullCredentialDetail(id) + } + } } } pullAPI.pullReports(null, 100).let { if (it is Result.Success) results.add(PullResultData("reports", it.data)) } diff --git a/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt b/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt index 9eb441e..e846f8b 100644 --- a/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt +++ b/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt @@ -1,5 +1,6 @@ package com.smoa.core.common.sync +import com.smoa.core.common.Result import javax.inject.Inject /** @@ -9,6 +10,12 @@ import javax.inject.Inject interface CredentialCacheSyncPort { suspend fun mergeFromPullCredentialsJson(json: ByteArray) + /** + * For rows missing [payloadJson], fetches full credential via [pullCredentialDetail] and merges. + * Called automatically after a successful credentials list pull from [com.smoa.core.common.SyncService]. + */ + suspend fun hydrateMissingPayloads(pullCredentialDetail: suspend (String) -> Result) + suspend fun upsertAfterCredentialSync(data: Any, serverTimestamp: Long, itemId: String) suspend fun removeCredential(credentialId: String) @@ -26,6 +33,10 @@ interface CredentialCacheSyncPort { class NoOpCredentialCacheSyncPort @Inject constructor() : CredentialCacheSyncPort { override suspend fun mergeFromPullCredentialsJson(json: ByteArray) = Unit + override suspend fun hydrateMissingPayloads( + pullCredentialDetail: suspend (String) -> Result + ) = Unit + override suspend fun upsertAfterCredentialSync(data: Any, serverTimestamp: Long, itemId: String) = Unit override suspend fun removeCredential(credentialId: String) = Unit diff --git a/core/common/src/test/java/com/smoa/core/common/SyncServiceTest.kt b/core/common/src/test/java/com/smoa/core/common/SyncServiceTest.kt index f7e2576..875705b 100644 --- a/core/common/src/test/java/com/smoa/core/common/SyncServiceTest.kt +++ b/core/common/src/test/java/com/smoa/core/common/SyncServiceTest.kt @@ -2,7 +2,9 @@ package com.smoa.core.common import com.smoa.core.common.SyncAPI import com.smoa.core.common.SyncResponse +import com.smoa.core.common.sync.CredentialCacheSyncPort import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -64,6 +66,24 @@ class SyncServiceTest { assertTrue(true) // Placeholder - would verify sync state } + @Test + fun `startSync merges and hydrates credentials after list pull`() = runTest { + val pull = mockk() + val cache = mockk() + coEvery { cache.mergeFromPullCredentialsJson(any()) } returns Unit + coEvery { cache.hydrateMissingPayloads(any()) } returns Unit + every { connectivityManager.isOnline() } returns true + coEvery { pull.pullDirectory(null) } returns Result.Success(ByteArray(0)) + coEvery { pull.pullOrders(null, 100, null) } returns Result.Success(ByteArray(0)) + coEvery { pull.pullEvidence(null, 100, null) } returns Result.Success(ByteArray(0)) + coEvery { pull.pullCredentials(null, 100, null) } returns Result.Success("[]".toByteArray()) + coEvery { pull.pullReports(null, 100) } returns Result.Success(ByteArray(0)) + val svc = SyncService(context, connectivityManager, syncAPI, pull, cache) + svc.startSync() + coVerify(exactly = 1) { cache.mergeFromPullCredentialsJson(any()) } + coVerify(exactly = 1) { cache.hydrateMissingPayloads(any()) } + } + @Test fun `checkOfflineDuration should return true when exceeded`() { // Given diff --git a/docs/README.md b/docs/README.md index a6b3274..c965115 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ This is the central index for all SMOA (Secure Mobile Operations Application) do ### Implementation - [Implementation Requirements](reference/IMPLEMENTATION_REQUIREMENTS.md) - Technical requirements - [Implementation Status](status/IMPLEMENTATION_STATUS.md) - Current implementation status (consolidated) +- [Complete Credential alignment](reference/IDENTITY-TEMPLATE-ALIGNMENT.md) — `CredentialRef.domain` mapping; umbrella runbook `complete-credential/docs/integrations/smoa.md` and ADR-0010 --- diff --git a/docs/development/OPENAPI-SYNCHRONIZATION.md b/docs/development/OPENAPI-SYNCHRONIZATION.md index 1554313..aca0ef6 100644 --- a/docs/development/OPENAPI-SYNCHRONIZATION.md +++ b/docs/development/OPENAPI-SYNCHRONIZATION.md @@ -9,13 +9,11 @@ The **running Spring Boot app** exposes the authoritative contract: The static file `docs/api/api-specification.yaml` is a **human-maintained reference** for design reviews. It can drift from springdoc. -## Optional CI drift check +## CI drift check (implemented baseline) -1. Start the backend locally: `./gradlew :backend:bootRun` (or run the JAR). -2. Export: `curl -s http://localhost:8080/v3/api-docs -o /tmp/smoa-openapi.json` -3. Compare relevant paths/schemas to your golden file or use a JSON diff tool. +`OpenApiContractIntegrationTest` (module `:backend`) loads the Spring context and asserts that `/v3/api-docs` includes expected paths (e.g. `/api/v1/sync/credential`, `/api/v1/credentials`, `/api/v1/directory`, `/api/v1/integrity/verify`). It runs as part of `./gradlew :backend:test` and therefore **`./gradlew smoaVerify`**. -For automation, run the backend in CI (Docker or Testcontainers), curl `/v3/api-docs`, and fail the job if the diff against a committed golden file is non-empty (after normalizing ordering if needed). +For a **full golden-file diff**, still optional: start the backend, `curl` `/v3/api-docs`, and compare to a committed artifact (normalize JSON ordering if needed). ## Script diff --git a/docs/reference/COMPLIANCE_MATRIX.md b/docs/reference/COMPLIANCE_MATRIX.md index 19c0904..578bd14 100644 --- a/docs/reference/COMPLIANCE_MATRIX.md +++ b/docs/reference/COMPLIANCE_MATRIX.md @@ -1,9 +1,12 @@ # SMOA Compliance Status Matrix ## Quick Reference Guide -**Last Updated:** 2024-12-20 -**Application:** Secure Mobile Operations Application (SMOA) v1.0 -**Version:** 1.0 +**Last Updated:** 2026-03-24 +**Application:** Secure Mobile Operations Application (SMOA) +**Version:** 1.1 + +**Authoritative task rows:** [TASKS.md](../../TASKS.md) · **External gates:** [TODO.md](../../TODO.md) +This matrix is refreshed against the repository periodically; it is **not** a legal attestation. --- @@ -11,18 +14,19 @@ 1. [Compliance Status Legend](#compliance-status-legend) 2. [Compliance Matrix](#compliance-matrix) -3. [Implementation Status](#implementation-status) -4. [See Also](#see-also) +3. [Priority Summary](#priority-summary) +4. [Implementation Roadmap](#implementation-roadmap) +5. [Risk Assessment](#risk-assessment) +6. [See Also](#see-also) --- ## Compliance Status Legend -- ✅ **COMPLIANT** - Fully implemented and compliant -- ⚠️ **PARTIAL** - Partially implemented, gaps exist -- ❌ **NON-COMPLIANT** - Not implemented or major gaps +- ✅ **COMPLIANT** - Implemented in codebase for intended scope (may still need deployment QA) +- ⚠️ **PARTIAL** - Framework, simulation, or incomplete vs formal standard +- ❌ **NON-COMPLIANT** - Not implemented or blocked by external dependency - N/A - Not applicable to this application -- 🔄 **IN PROGRESS** - Implementation in progress --- @@ -32,159 +36,138 @@ |---------------------|--------|----------|----------------------|-------| | **eIDAS (EU)** | | | | | | Multi-Factor Authentication | ✅ | P1 | Implemented | PIN + Biometric | -| Qualified Electronic Signatures (QES) | ❌ | P1 | Not Started | Requires QTSP integration | -| Qualified Certificates | ❌ | P1 | Not Started | Certificate management needed | -| Qualified Timestamping | ❌ | P1 | Not Started | TSA integration required | -| Electronic Seals | ❌ | P2 | Not Started | Legal entity seals | -| Identity Assurance Levels | ⚠️ | P2 | Partial | Basic assurance, no certification | -| Immutable Audit Records | ⚠️ | P1 | Partial | Basic logging exists | -| **Central Bureau Standards** | | | | | -| Credential Format Standards | ❌ | P1 | Not Started | Agency-specific formats | +| Qualified Electronic Signatures (QES) | ❌ | P1 | External / product gate | QTSP, trust lists — see [EIDASService](../../core/eidas/) | +| Qualified Certificates | ❌ | P1 | Not production | No EU trust list validation | +| Qualified Timestamping | ❌ | P1 | Not Started | TSA integration | +| Electronic Seals | ⚠️ | P2 | Partial | SHA-256 verify in `ElectronicSealService`; not qualified seal | +| Identity Assurance Levels | ⚠️ | P2 | Partial | No formal LOA labeling | +| Immutable Audit Records | ⚠️ | P1 | Partial | Audit/evidence models; formal immutability policy varies by deploy | +| **Central Bureau / credentialing** | | | | | +| Credential Format Standards | ⚠️ | P1 | Partial | `SmoaCredentialType`, templates, Room cache, pull/sync | | Authority Delegation | ❌ | P1 | Not Started | Chain-of-command tracking | -| Central Identifier Schemes | ❌ | P1 | Not Started | Multi-agency IDs | -| Credential Revocation | ⚠️ | P1 | Partial | Policy-based, no OCSP/CRL | +| Central Identifier Schemes | ⚠️ | P1 | Partial | Holder/ORI/UCN fields in domain models | +| Credential Revocation | ⚠️ | P1 | Partial | Local CRL + optional HTTP CDP fetch in `CertificateManager`; OCSP not wired | | Cross-Agency Validation | ❌ | P2 | Not Started | Federated validation | -| **PDF417 Barcode (PDF-147)** | | | | | -| PDF417 Generation | ❌ | P1 | Not Started | ISO/IEC 15438 compliance | -| AAMVA DL/ID Format | ❌ | P1 | Not Started | Driver license format | -| ICAO 9303 Format | ❌ | P1 | Not Started | Travel document format | -| Barcode Display | ❌ | P1 | Not Started | High-res rendering | -| Barcode Scanning | ❌ | P2 | Not Started | Camera-based validation | -| Error Correction Levels | ❌ | P2 | Not Started | Levels 0-8 support | +| **PDF417 / ISO/IEC 15438** | | | | | +| PDF417 Generation | ✅ | P1 | Implemented | `core/barcode` | +| AAMVA DL/ID Format | ⚠️ | P1 | Partial | Encoder + credential type; jurisdiction QA / vectors — see GAPS doc | +| ICAO 9303 Format | ⚠️ | P1 | Partial | Encoder + `icao9303_mrtd`; production QA pending | +| Barcode Display | ✅ | P1 | Implemented | Compose + ZXing | +| Barcode Scanning | ⚠️ | P2 | Partial | Scanner integration present; device QA per deploy | +| Error Correction Levels | ✅ | P2 | Implemented | Levels 0–8 in generator | | **ATF / Law Enforcement** | | | | | -| ATF Form Support | ❌ | P1 | Not Started | Form 4473, Form 1, Form 4 | -| ATF eTrace Integration | ❌ | P1 | Not Started | Firearms tracing | -| NCIC Integration | ❌ | P1 | Not Started | National crime database | -| III Integration | ❌ | P1 | Not Started | Interstate identification | -| ORI/UCN Support | ❌ | P1 | Not Started | LE identifiers | -| Evidence Chain of Custody | ❌ | P1 | Not Started | NIST SP 800-88 | -| NIBRS Reporting | ❌ | P1 | Not Started | Incident reporting | -| UCR Format | ❌ | P1 | Not Started | Uniform crime reporting | -| Warrant Management | ❌ | P1 | Not Started | Digital warrant storage | -| Case Management | ❌ | P2 | Not Started | Case file system | -| **Diplomatic Credentialing** | | | | | -| Diplomatic Note Formats | ❌ | P1 | Not Started | Consular standards | -| ICAO 9303 Travel Docs | ❌ | P1 | Not Started | Machine-readable docs | -| Official Seal Rendering | ❌ | P1 | Not Started | High-fidelity seals | -| Diplomatic Immunity | ❌ | P2 | Not Started | Vienna Convention | -| Credential Hierarchy | ❌ | P2 | Not Started | Principal/dependent/staff | -| Consular DB Integration | ❌ | P2 | Not Started | Real-time validation | -| Multi-Language Support | ⚠️ | P2 | Partial | Basic i18n needed | -| **AS4 Gateway Compliance** | | | | | -| AS4 Message Envelope | ❌ | P1 | Not Started | OASIS AS4 Profile 1.0 | -| WS-Security | ⚠️ | P1 | Partial | Basic encryption, no SOAP headers | -| XML Digital Signature | ❌ | P1 | Not Started | XMLDSig compliance | -| XML Encryption | ❌ | P1 | Not Started | XMLEnc compliance | -| WS-ReliableMessaging | ❌ | P1 | Not Started | Reliable delivery | -| AS4 Pull Protocol | ❌ | P2 | Not Started | Message polling | -| MPC Support | ❌ | P2 | Not Started | Multi-destination routing | -| Receipt Handling | ❌ | P1 | Not Started | Non-repudiation | -| Error Signals | ❌ | P1 | Not Started | Standard error handling | -| CPA Management | ❌ | P2 | Not Started | Partner agreements | +| ATF Form Support | ⚠️ | P1 | Partial | Models, Room drafts — live eTrace external | +| ATF eTrace Integration | ❌ | P1 | External gate | Federal approval | +| NCIC Integration | ⚠️ | P1 | Partial | Service + local log; live API CJIS | +| III Integration | ⚠️ | P1 | Partial | Framework; live external | +| ORI/UCN Support | ⚠️ | P1 | Partial | In NCIC/query models | +| Evidence Chain of Custody | ⚠️ | P1 | Partial | Room, transfers, UI module — formal NIST SP 800-88 program per agency | +| NIBRS Reporting | ❌ | P1 | Not Started | | +| UCR Format | ❌ | P1 | Not Started | | +| Warrant Management | ⚠️ | P1 | Partial | Orders module (warrant types in domain) | +| Case Management | ⚠️ | P2 | Partial | Judicial module framework | +| **Diplomatic credentialing** | | | | | +| Diplomatic Note Formats | ❌ | P1 | Not Started | | +| ICAO 9303 Travel Docs | ⚠️ | P1 | Partial | Same as PDF417/ICAO row | +| Official Seal Rendering | ❌ | P1 | Not Started | | +| Diplomatic Immunity | ❌ | P2 | Not Started | | +| Credential Hierarchy | ❌ | P2 | Not Started | | +| Consular DB Integration | ❌ | P2 | Not Started | | +| Multi-Language Support | ⚠️ | P2 | Partial | | +| **AS4 Gateway** | | | | | +| AS4 Message Envelope | ⚠️ | P1 | Partial | Framework / stub — [core/as4/README.md](../../core/as4/README.md) | +| WS-Security | ⚠️ | P1 | Partial | | +| XML Digital Signature | ❌ | P1 | Not Started | Full XMLDSig interop | +| XML Encryption | ❌ | P1 | Not Started | | +| WS-ReliableMessaging | ❌ | P1 | Not Started | | +| AS4 Pull Protocol | ❌ | P2 | Not Started | | +| MPC Support | ❌ | P2 | Not Started | | +| Receipt Handling | ❌ | P1 | Not Started | | +| Error Signals | ❌ | P1 | Not Started | | +| CPA Management | ❌ | P2 | Not Started | | | **ISO Standards** | | | | | -| ISO/IEC 27001 (ISMS) | ⚠️ | P2 | Partial | Controls exist, no formal ISMS | -| ISO/IEC 15438 (PDF417) | ❌ | P1 | Not Started | See PDF417 section | -| ISO/IEC 7816 (Smart Cards) | ❌ | P3 | Not Started | APDU support | -| ISO/IEC 19794 (Biometrics) | ⚠️ | P2 | Partial | Android APIs, no ISO templates | -| ISO 8601 (Date/Time) | ⚠️ | P2 | Partial | Verify compliance | -| ISO 3166 (Country Codes) | ⚠️ | P2 | Partial | Verify usage | +| ISO/IEC 27001 (ISMS) | ⚠️ | P2 | Partial | Controls in app; no formal ISMS cert | +| ISO/IEC 15438 (PDF417) | ✅ | P1 | Implemented | Via barcode module | +| ISO/IEC 7816 (Smart Cards) | ❌ | P3 | Stub | `SmartCardReader` not bound to PC/SC | +| ISO/IEC 19794 (Biometrics) | ⚠️ | P2 | Partial | Platform APIs; limited ISO templates | +| ISO 8601 (Date/Time) | ⚠️ | P2 | Partial | | +| ISO 3166 (Country Codes) | ⚠️ | P2 | Partial | `CountryCodes` helper | | **Reporting & Orders** | | | | | -| Report Generation | ❌ | P1 | Not Started | Multi-format exports | -| Orders Management | ❌ | P1 | Not Started | Digital orders system | -| Order Copy Provision | ❌ | P1 | Not Started | Authenticated copies | -| Regulatory Reporting | ❌ | P1 | Not Started | NIBRS, UCR, etc. | -| Evidence Reports | ❌ | P1 | Not Started | Documentation reports | -| Compliance Reports | ❌ | P2 | Not Started | Audit compliance | +| Report Generation | ⚠️ | P1 | Partial | Multi-format generator + UI | +| Orders Management | ⚠️ | P1 | Partial | Room, sync, list/detail UI | +| Order Copy Provision | ⚠️ | P1 | Partial | Domain support; policy per deploy | +| Regulatory Reporting | ❌ | P1 | Not Started | NIBRS/UCR | +| Evidence Reports | ⚠️ | P1 | Partial | Reports + evidence modules | +| Compliance Reports | ⚠️ | P2 | Partial | Report types in domain | | **Military Operations** | | | | | -| MIL-STD-2525 (Symbols) | ❌ | P1 | Not Started | Warfighting symbology | -| MIL-STD-129 (IDs) | ❌ | P1 | Not Started | Military identification | -| JTF Integration | ❌ | P2 | Not Started | Joint task force tools | -| Classification Markings | ❌ | P1 | Not Started | DOD classification levels | +| MIL-STD-2525 (Symbols) | ⚠️ | P1 | Partial | Module support | +| MIL-STD-129 (IDs) | ⚠️ | P1 | Partial | Credential template | +| JTF Integration | ❌ | P2 | Not Started | | +| Classification Markings | ⚠️ | P1 | Partial | Military + remote watermark config | | DODI 8500.01 | ⚠️ | P1 | Partial | Security controls partial | | **Judicial Operations** | | | | | -| Court Order Management | ❌ | P1 | Not Started | Digital court orders | -| Case File Management | ❌ | P1 | Not Started | Judicial case system | -| Subpoena Management | ❌ | P1 | Not Started | Subpoena workflow | -| Sealed Records | ❌ | P1 | Not Started | Enhanced access controls | -| Court Scheduling | ❌ | P2 | Not Started | Calendar integration | +| Court Order Management | ⚠️ | P1 | Partial | Judicial + orders overlap | +| Case File Management | ⚠️ | P1 | Partial | Framework | +| Subpoena Management | ⚠️ | P1 | Partial | Framework | +| Sealed Records | ❌ | P1 | Not Started | | +| Court Scheduling | ❌ | P2 | Not Started | | | **Intelligence Operations** | | | | | -| Compartmented Access | ❌ | P1 | Not Started | Multi-level security | -| SCI Handling | ❌ | P1 | Not Started | Sensitive compartmented info | -| ICD 503 Compliance | ❌ | P1 | Not Started | IC security directive | -| ICD 704 Compliance | ❌ | P1 | Not Started | Personnel security | -| Source Protection | ❌ | P1 | Not Started | Source handling protocols | -| Classification Lifecycle | ❌ | P2 | Not Started | Declassification rules | +| Compartmented Access | ⚠️ | P1 | Partial | MLS framework in module | +| SCI Handling | ⚠️ | P1 | Partial | Framework | +| ICD 503 Compliance | ❌ | P1 | Not Started | | +| ICD 704 Compliance | ❌ | P1 | Not Started | | +| Source Protection | ⚠️ | P1 | Partial | Framework | +| Classification Lifecycle | ❌ | P2 | Not Started | | --- ## Priority Summary -### Priority 1 (P1) - Critical -- **Total Requirements:** 45 -- **Compliant:** 1 (2%) -- **Partial:** 6 (13%) -- **Non-Compliant:** 38 (84%) +Approximate counts from the matrix above (P1/P2/P3 rows only): -### Priority 2 (P2) - High -- **Total Requirements:** 20 -- **Compliant:** 0 (0%) -- **Partial:** 4 (20%) -- **Non-Compliant:** 16 (80%) +| Priority | ✅ | ⚠️ | ❌ | +|----------|----|----|-----| +| P1 | ~8 | ~28 | ~22 | +| P2 | ~1 | ~8 | ~11 | +| P3 | 0 | 0 | 1 | -### Priority 3 (P3) - Medium -- **Total Requirements:** 1 -- **Non-Compliant:** 1 (100%) +Use [COMPLIANCE_EVALUATION.md](COMPLIANCE_EVALUATION.md) for narrative gap analysis. --- ## Implementation Roadmap -### Immediate (0-3 months) -Focus on foundational P1 items: -- PDF417 barcode generation -- Orders management module -- Basic report generation -- Evidence chain of custody +### Done in repo (baseline) +- PDF417 generation/display, credential sync/pull + cache, backend sync/pull API, orders/evidence/reports UI layers, Android enterprise auth/pinning (see TASKS.md). -### Short-term (3-6 months) -- AS4 envelope implementation -- ATF form support -- NCIC/III integration framework -- Credential format parsers +### Near-term (engineering) +- Play Integrity **server** verification; WebRTC/media; smart card driver; OCSP; stronger tenant **data** scoping (beyond `X-Unit` attribute). +- AAMVA/ICAO production test vectors and jurisdiction sign-off. -### Medium-term (6-12 months) -- Full AS4 gateway -- Domain-specific standards -- Regulatory reporting -- Enhanced audit capabilities - -### Long-term (12-24 months) -- eIDAS qualified signatures -- Intelligence community standards -- Full certification and accreditation -- Advanced domain-specific features +### External / legal +- NCIC/III, ATF eTrace, QTSP, full AS4 CPA, Knox/WebRTC binaries, shipped iOS app. --- ## Risk Assessment -### High Risk Areas -1. **AS4 Gateway** - Blocking inter-agency communication -2. **Law Enforcement Standards** - Blocking LE operations -3. **PDF417 Barcodes** - Blocking credential presentation -4. **Orders Management** - Blocking operational authorization +### High residual risk +- Live law-enforcement and identity systems (NCIC, CJIS, qualified trust services). +- AS4/XML interop without partner CPA. -### Medium Risk Areas -1. **eIDAS Compliance** - Blocks EU operations -2. **Diplomatic Standards** - Limits diplomatic use -3. **Military Standards** - Limits military deployment +### Medium +- eIDAS-qualified workflows; multi-tenant isolation until RLS or token claims enforce data bounds. -### Low Risk Areas -1. **Smart Card Integration** - Enhancement feature -2. **Advanced Biometric Formats** - Interoperability enhancement +### Lower +- Smart card reader binding per deployment; extended biometric interchange formats. --- -**Document Version:** 1.0 -**Next Review:** Quarterly or after major implementation milestones +## See Also +- [COMPLIANCE_EVALUATION.md](COMPLIANCE_EVALUATION.md) +- [GAPS-AND-INCONSISTENCIES.md](GAPS-AND-INCONSISTENCIES.md) +- [IMPLEMENTATION_STATUS.md](../status/IMPLEMENTATION_STATUS.md) + +**Document Version:** 1.1 +**Next Review:** Quarterly or after major milestones diff --git a/docs/reference/GAPS-AND-INCONSISTENCIES.md b/docs/reference/GAPS-AND-INCONSISTENCIES.md index ad4eee6..895afd9 100644 --- a/docs/reference/GAPS-AND-INCONSISTENCIES.md +++ b/docs/reference/GAPS-AND-INCONSISTENCIES.md @@ -1,6 +1,6 @@ # Gaps and inconsistencies (cross-cutting audit) -**Last reviewed:** 2026-03-23 +**Last reviewed:** 2026-03-24 (credential hydration wired in `SyncService`) Most previously tracked gaps are **implemented**. This file lists only **long-horizon** or **compliance** items. @@ -15,6 +15,7 @@ Most previously tracked gaps are **implemented**. This file lists only **long-ho | Browser VPN policy flag | `BuildConfig.SMOA_BROWSER_VPN_ENFORCED` / `-Psmoa.browser.vpnEnforced=true`, `VPNManager.setBrowserVpnEnforced` | | Room credential cache | `credential_cache` + `CredentialCacheDatabaseModule` | | OpenAPI drift process | `docs/development/OPENAPI-SYNCHRONIZATION.md`, `scripts/export-openapi-local.sh` | +| Credential pull → cache + payloads | `SyncService.startSync`: `CredentialCacheSyncPort.mergeFromPullCredentialsJson` then `hydrateMissingPayloads { pullCredentialDetail }`. UI: `CredentialsViewModel` | --- @@ -24,13 +25,13 @@ Most previously tracked gaps are **implemented**. This file lists only **long-ho |-------|--------| | **Strong multi-tenant isolation** | API key + `X-Unit` are not RLS; see `docs/security/TENANT-THREAT-MODEL.md`. | | **AAMVA / ICAO production compliance** | Encoders need jurisdiction QA and official test vectors. | -| **Automated OpenAPI golden-file CI** | Documented; wire Testcontainers + diff in CI when ready. | -| **Credential cache population** | DAO/DB exist; merge pull results into `credential_cache` in a dedicated repository/use-case when product requires offline credential lists. | +| **Automated OpenAPI golden-file CI** | **Partial:** `OpenApiContractIntegrationTest` in `:backend:test` (also `smoaVerify`) asserts key `/v3/api-docs` paths. Full committed golden JSON diff still optional. | --- ## Related +- **[Outstanding production & compliance work (detailed)](./OUTSTANDING-PRODUCTION-AND-COMPLIANCE-WORK.md)** — expands long-horizon items into deliverables, prerequisites, and acceptance criteria. - `docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md` - `docs/schemas/` - `backend/docs/BACKEND-GAPS-AND-ROADMAP.md` diff --git a/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md b/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md index fb22e4e..b2b4e4c 100644 --- a/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md +++ b/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md @@ -119,8 +119,19 @@ The **`credential-payload-document.schema.json`** `oneOf` union is ambiguous wit | Artifact | Path | |----------|------| +| **Complete Credential — integration runbook + ADR-0010** | Umbrella repo: `docs/integrations/smoa.md`, `adr/0010-smoa-mobile-integration-boundary.md` (when cloned as e.g. `../complete-credential/`) | | Client config example (FQDN / endpoints) | `backend/docs/examples/smoa-client-config.example.json` | | LXC / hosting | `backend/docs/LXC-PROXMOX-CONTAINERS.md` | | Backend credential entity | `backend/.../CredentialEntity.kt` | | Sync DTO | `backend/.../CredentialSyncRequest` in `SyncRequest.kt` | | OpenAPI (SMOA API) | `docs/api/api-specification.yaml` | + +### 7.1 Contract drift check (with Complete Credential clone) + +From the **complete-credential** repository root (submodules initialized): + +```bash +SMOA_REPO=/path/to/smoa ./tools/verify/verify-smoa-cc-domain-alignment.sh +``` + +This asserts the shared `CredentialRef.domain` line in `cc-shared-schemas` and that `SmoaCredentialType.kt` still contains literals `payment`, `piv_pki`, `nfc_access`, `mobile`. diff --git a/docs/reference/OUTSTANDING-PRODUCTION-AND-COMPLIANCE-WORK.md b/docs/reference/OUTSTANDING-PRODUCTION-AND-COMPLIANCE-WORK.md new file mode 100644 index 0000000..4cca656 --- /dev/null +++ b/docs/reference/OUTSTANDING-PRODUCTION-AND-COMPLIANCE-WORK.md @@ -0,0 +1,322 @@ +# Outstanding production and compliance work (“not completed in-repo”) + +**Purpose:** This document expands everything that was explicitly **out of scope** for in-repository implementation—typically because it depends on **agency approval**, **infrastructure you operate**, **formal certification**, or **long-running QA**—into actionable work packages with prerequisites, deliverables, and acceptance criteria. + +**Audience:** Security officers, backend/platform owners, mobile leads, and integration engineers planning a production rollout. + +**Related audits:** [`GAPS-AND-INCONSISTENCIES.md`](./GAPS-AND-INCONSISTENCIES.md), [`backend/docs/BACKEND-GAPS-AND-ROADMAP.md`](../../backend/docs/BACKEND-GAPS-AND-ROADMAP.md), [`docs/security/TENANT-THREAT-MODEL.md`](../security/TENANT-THREAT-MODEL.md). + +--- + +## Executive summary + +| Workstream | Why it is “not done here” | Primary owner | +|------------|---------------------------|----------------| +| Strong tenant isolation | Requires policy + DB architecture + often IdP | Platform / security / DBA | +| AAMVA / ICAO barcode production readiness | Jurisdiction-specific test vectors and legal use | Compliance + mobile + issuing authority | +| OpenAPI golden CI | CI images, Testcontainers, golden file governance | DevOps + backend | +| CJIS / live NCIC–III | CJIS Security Policy, contracts, accredited networks | Agency + integration | +| ATF / NFA eForms (live) | Federal credentials and APIs | Agency + legal | +| WebRTC (production) | TURN/signaling ops and scale | Infrastructure + mobile | +| Enterprise security (full) | IdP-specific, device program, backend verification | Security + mobile + backend | +| E2E / release QA | Tooling and environments | QA + DevOps | +| Formal assessments | External process | Authorizing official | + +--- + +## 1. Strong multi-tenant isolation + +### Current state (repository) + +- Optional **API key** on `/api/v1/*` when `SMOA_API_KEY` is set; key does **not** encode tenant or unit membership. +- **`X-Unit`** and `smoa.tenant.require-unit` can force clients to send a unit header; this is **not** proof of membership and is **not** row-level isolation. +- PostgreSQL (when used in prod) has **no RLS** policies in this codebase; queries rely on application-layer filters where implemented. + +**References:** [`docs/security/TENANT-THREAT-MODEL.md`](../security/TENANT-THREAT-MODEL.md), [`backend/docs/BACKEND-GAPS-AND-ROADMAP.md`](../../backend/docs/BACKEND-GAPS-AND-ROADMAP.md) §9. + +### Goal + +Ensure that **compromise of one tenant’s credential** (or one device’s API access) cannot read or mutate another tenant’s data at scale—aligned with your **threat model** and **data classification**. + +### Prerequisites + +- Defined **tenant model** (org id, unit hierarchy, holder scope). +- Identity story: **mTLS**, **JWT** with `tenant_id` / `sub` / scopes, or **OAuth2 client credentials** per tenant/device class. +- PostgreSQL (or equivalent) as system of record with migration path from dev H2. + +### Deliverables + +1. **Authentication:** Replace or supplement shared API key with per-tenant or per-device credentials where policy requires it. +2. **Authorization policy:** Map principal → **allowed units** (and optional holder IDs); centralize in a service or Spring `SecurityContext` claims. +3. **Enforcement:** Audit **every** sync/pull/delete handler; reject cross-tenant IDs; add integration tests that prove isolation. +4. **Database:** Implement **PostgreSQL RLS** *or* **schema-per-tenant** *or* strict **tenant_id** column + mandatory predicate on every query (with query review). +5. **Observability:** Correlate `X-Request-Id`, principal, tenant, and `sync_audit_log` for incident response. + +### Acceptance criteria + +- Penetration test or internal red-team scenario: valid token for **Tenant A** cannot read/write **Tenant B** data (including ID enumeration and bulk export). +- Documented **key rotation** and **breach response** for compromised client credentials. +- RLS/policy review signed off by DBA + security. + +--- + +## 2. AAMVA / ICAO (and related) barcode production compliance + +### Current state (repository) + +- Kotlin encoders/decoders and PDF417 pipeline exist under **`core/barcode`** (e.g. AAMVA DL/ID, ICAO 9303 MRTD, MIL-STD-129). +- README and compliance sections describe **standards alignment**; they do **not** constitute jurisdiction acceptance. + +**References:** [`docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md`](./IDENTITY-TEMPLATE-ALIGNMENT.md), [`docs/schemas/`](../schemas/), barcode modules under `core/barcode/`. + +### Goal + +Barcode generation and parsing are **correct and permitted** for each **deploying jurisdiction** and **credential issuer** (motor vehicle agency, passport office, defense ID program, etc.). + +### Prerequisites + +- Identify **which credential types** are in scope for each deployment. +- Obtain **official test vectors**, **spec versions**, and **errata** from issuing authorities (AAMVA circulars, ICAO Doc 9303 parts, national supplements). + +### Deliverables + +1. **Per-format test suite:** Golden vectors from authorities; negative tests for malformed inputs. +2. **Jurisdiction matrix:** Which fields are mandatory/optional per state/country/program. +3. **Legal / policy:** Data minimization, display rules, and retention for barcode contents. +4. **Field QA:** Visual/scan QA on target devices (foldable phones, scanners) at required DPI/lighting. +5. **Change control:** Process when AAMVA/ICAO updates specs. + +### Acceptance criteria + +- Sign-off from **issuing authority** or **program security** where required. +- Regression suite runs in CI for barcode modules; failures block release for in-scope formats. + +--- + +## 3. Automated OpenAPI golden-file CI + +### Current state (repository) + +- Springdoc exposes **`GET /v3/api-docs`** as the live contract. +- Static **`docs/api/api-specification.yaml`** is maintained for humans and can **drift**. +- Manual process documented in [`docs/development/OPENAPI-SYNCHRONIZATION.md`](../development/OPENAPI-SYNCHRONIZATION.md); scripts such as `scripts/export-openapi-local.sh` assist locally. + +### Goal + +CI fails when the **running backend contract** changes without updating the **committed golden artifact** (or an approved migration). + +### Prerequisites + +- CI runner with Docker or JVM support for **Testcontainers** (or a lightweight boot of the Spring app). +- Decision: golden file is **JSON** from `/v3/api-docs`, **normalized YAML**, or **both**. + +### Deliverables + +1. **CI job** (e.g. Gitea Actions) that: + - Starts backend with **test profile** (H2 in-memory is acceptable). + - Fetches `/v3/api-docs`. + - **Normalizes** JSON (sort keys, stable ordering) to avoid noise. + - **Diffs** against `docs/api/generated/openapi-golden.json` (or agreed path). +2. **Update workflow:** When API intentionally changes, developer updates golden file in the same PR with rationale in commit/PR description. +3. **Optional:** Diff against `docs/api/api-specification.yaml` if you keep YAML as second source—define which wins. + +### Acceptance criteria + +- Intentional controller change without golden update → **red CI**. +- Documented **escape hatch** for emergency hotfix (e.g. label + follow-up ticket) if policy allows. + +--- + +## 4. CJIS and live NCIC / III integration + +### Current state (repository) + +- **NCIC module** validates ORI/UCN patterns, runs **simulated** queries, and logs audit events. +- README mentions **CJIS Security Policy** as a compliance target; the app does **not** connect to FBI CJIS systems. + +### Goal + +Lawful, accredited use of **NCIC** and related **III** capabilities per **CJIS Security Policy** and agency agreements. + +### Prerequisites + +- **CJIS compliance** program: networking, personnel screening, workstation security, encryption, auditing, agreements. +- **ORI** and **connectivity** to approved CJIS channels (e.g. state CJIS systems, VPN, MFA). +- Contracted **API or message interface** (often not public; varies by state/federal partner). + +### Deliverables + +1. **Security controls** mapped to CJIS policy areas (identification, encryption, media protection, incident response). +2. **Replace simulation** in `NCICService` with real transport (HTTPS client certs, VPN-only endpoints, or middleware). +3. **Audit and retention** meeting CJIS logging requirements; **no PII** in mobile logs beyond policy. +4. **Training and SOPs** for operators (hit/no-hit handling, privacy, civil rights). + +### Acceptance criteria + +- **CJIS audit** or state-equivalent authorization to operate (ATO) as required. +- Legal review for **query justification** and **use of criminal justice information** on mobile devices. + +--- + +## 5. ATF eForms / eTrace and live NFA workflows + +### Current state (repository) + +- **ATF module** validates Form 4473 fields and **simulates** submission; Forms 1 and 4 are documented stubs. +- Production use requires **federal systems** and **approved credentials** not present in the repo. + +### Goal + +Legally compliant firearms transaction and NFA workflows where your organization is authorized to use **ATF electronic systems**. + +### Prerequisites + +- **FFL** status and **API / eForms** enrollment as applicable. +- **OAuth or certificate** credentials issued by ATF or intermediary. +- Legal review of **data stored on device** vs **ATF systems of record**. + +### Deliverables + +1. Replace simulation with **real API clients** (timeouts, retries, idempotency). +2. **Secure storage** of any client secrets (HSM, MDM, backend proxy pattern). +3. **Error taxonomy** aligned with ATF responses; operator messaging that avoids leaking sensitive data. + +### Acceptance criteria + +- Sign-off from **compliance/legal** and **ATF program** contacts. +- Test environment validation before production keys. + +--- + +## 6. WebRTC, TURN, and signaling (production) + +### Current state (repository) + +- Meetings/communications modules include **stubs or framework** code; **WebRTCManager** notes production peer connections are not fully wired. +- Documentation for **Coturn** and signaling URLs exists. + +**References:** [`docs/infrastructure/TURN-SIGNALING.md`](../infrastructure/TURN-SIGNALING.md), `modules/communications/`, `modules/meetings/`. + +### Goal + +Reliable **NAT traversal**, **media path**, and **signaling** at expected scale and security posture. + +### Deliverables + +1. Deploy **TURN** (e.g. Coturn) with **TLS**, **time-limited credentials** (HMAC), monitoring. +2. Deploy **signaling** service (WebSocket or long-poll); configure `SMOA_SIGNALING_URLS` / build-time props. +3. Complete Android **PeerConnection** integration, codecs, and **screen share** policy if required. +4. Load and **failure testing**; logging without storing raw media. + +### Acceptance criteria + +- SLO for call setup time and drop rate in pilot environment. +- Security review for **ICE**, **DTLS-SRTP**, and **metadata** leakage. + +--- + +## 7. Enterprise security (full operationalization) + +### Current state (repository) + +- **TLS pinning**, **OIDC placeholders**, **session lock**, **Play Integrity hook**, **Knox probe**, **BiometricSecretsVault** scaffolding are documented and partially implemented. + +**Reference:** [`docs/development/SECURITY-ENTERPRISE.md`](../development/SECURITY-ENTERPRISE.md). + +### Goal + +Each control is **configured**, **tested**, and **operationally owned** for your IdP and device fleet. + +### Work items + +| Item | What “done” looks like | +|------|-------------------------| +| **TLS pinning** | Pins for all API hosts; rotation runbook; optional `RemoteEndpointStore` pins from hosted config (see doc). | +| **OIDC / AppAuth** | End-to-end code exchange; refresh; logout; token revocation policy. | +| **Play Integrity** | `cloudProjectNumber` set; **backend** verifies tokens with Google API; abuse signals integrated. | +| **Knox / MDM** | Knox SDK or UEM policies enforced (not just classpath detection). | +| **BiometricSecretsVault** | Full `CryptoObject` path bound to refresh or high-value keys per your IdP design. | +| **Classification marking** | `smoa.classification.buildMarking` aligned with organizational data labeling; content-level markings if required. | + +### Acceptance criteria + +- **Security architecture review** with traceability from threat model to controls. +- **Operational runbooks** for rotation, incident, and user lockout. + +--- + +## 8. End-to-end and pre-release testing + +### Current state (repository) + +- **Unit and integration tests** run via `./gradlew smoaVerify` (backend + Android unit + debug assemble). +- E2E is **planned**, not implemented as an automated suite. + +**Reference:** [`docs/testing/E2E-PLAN.md`](../testing/E2E-PLAN.md). + +### Goal + +Repeatable **user-journey** validation before release (sync, auth, meetings, browser policy, etc.). + +### Deliverables + +1. Choose stack: **Maestro**, **Appium**, or **Espresso + MockWebServer** (per E2E-PLAN). +2. **Seed data** and **test backend** (container or dedicated env). +3. **Nightly** or **pre-release** pipeline; keep **smoaVerify** on every push. + +### Acceptance criteria + +- Critical paths (e.g. sign-in, directory pull, credential display) automated with **flake budget** and ownership. + +--- + +## 9. iOS and additional clients + +### Current state (repository) + +- **Android** is the primary implementation; **iOS** documentation exists under `docs/ios/` as guidance/samples. + +### Goal (if in program scope) + +Feature parity and **shared contract** compliance for an iOS app or SDK. + +### Deliverables + +- Contract tests against same **OpenAPI**; shared **credential type** constants; platform-specific secure storage. + +--- + +## 10. Formal assessments and authorization + +### Examples (deployment-dependent) + +- **CJIS** Security Policy compliance assessment. +- **FedRAMP**, **StateRAMP**, **HIPAA**, **SOC 2**, or **department-specific ATO** packages. +- **Country export** and **crypto** regulations for international deployments. + +### Deliverables + +- System security plan (SSP), control narratives, evidence from CI and ops. +- POA&M for gaps discovered during assessment. + +--- + +## 11. Suggested sequencing (non-binding) + +1. **Tenant isolation + auth model** (blocks safe multi-org production). +2. **OpenAPI golden CI** (cheap win; prevents silent contract drift). +3. **E2E smoke** on top 3 flows. +4. **Barcode / issuance QA** for each in-scope credential type. +5. **Agency integrations** (CJIS, ATF) as approvals arrive. +6. **WebRTC** when meetings are production-critical. +7. **Formal ATO** package in parallel with engineering hardening. + +--- + +## Document control + +| Version | Date | Notes | +|---------|------|--------| +| 1.0 | 2026-03-23 | Initial consolidation of “not done here” items from gaps, threat model, backend roadmap, OpenAPI, E2E, security enterprise, infrastructure docs. | + +When a workstream is completed, update this file and trim or move the section to [`GAPS-AND-INCONSISTENCIES.md`](./GAPS-AND-INCONSISTENCIES.md) **Implemented** table as appropriate. diff --git a/docs/status/IMPLEMENTATION_STATUS.md b/docs/status/IMPLEMENTATION_STATUS.md index 98f4fae..4b029a1 100644 --- a/docs/status/IMPLEMENTATION_STATUS.md +++ b/docs/status/IMPLEMENTATION_STATUS.md @@ -1,8 +1,8 @@ # SMOA Implementation Status -**Date:** 2024-12-20 -**Status:** ✅ **ALL CODE IMPLEMENTATION FRAMEWORKS COMPLETE** -**Version:** 1.0 +**Date:** 2026-03-24 +**Status:** ✅ **Frameworks complete; integration and compliance work continues** (see [Remaining Work](#remaining-work)) +**Version:** 1.1 --- @@ -197,63 +197,26 @@ For detailed compliance information, see: ## Remaining Work -**See [TODO.md](../../TODO.md)** (status vs external gates) and **[TASKS.md](../../TASKS.md)** (master task table with file links). +**Primary index:** [TASKS.md](../../TASKS.md) (file-level status) · [TODO.md](../../TODO.md) (repo vs external gates) · [GAPS-AND-INCONSISTENCIES.md](../reference/GAPS-AND-INCONSISTENCIES.md). -### Next steps (short-term) +The Android app **already uses Retrofit** for sync/pull (`BackendSyncAPI`, `BackendPullAPI`, `SyncRetrofitHolder`). Backend **GET pull** and **POST sync** endpoints are implemented; see [backend/README.md](../../backend/README.md). -1. **Backend:** Run `./gradlew :backend:test` and fix any failures; add integration tests for sync/pull/health. -2. **Android 16:** When upgrading AGP to 8.5+, set `compileSdk = 36`, `targetSdk = 36` (see [ANDROID-16-TARGET.md](../reference/ANDROID-16-TARGET.md)). -3. **Web:** Expand [web scaffold](../web-scaffold/index.html) (directory pull and status UI are in place); optional: React/Vue SPA, build pipeline, CORS in production. -4. **iOS / Web Dapp:** Full apps are separate codebases; use [docs/ios/README.md](../ios/README.md) and web scaffold as starting points. -5. **Domain/compliance:** NCIC, ATF, eIDAS QTSP, full WebRTC/AS4/signing require external approvals or larger implementations; extend stubs as needed. +### Engineering backlog (in-repo) -### High Priority (Future Enhancements) +1. **Media / hardware:** WebRTC AAR + signaling; `SmartCardReader` PC/SC or OEM binding. +2. **Backend:** Play Integrity token verification (Google API) — endpoint currently returns 501; row-level tenant isolation beyond `X-Unit` (see `TenantFilter` request attribute + threat model). +3. **Trust:** OCSP client; online revocation policy tied to deployments. +4. **Compliance QA:** AAMVA/ICAO official test vectors and jurisdiction sign-off ([GAPS](../reference/GAPS-AND-INCONSISTENCIES.md)). +5. **Tests:** Android coverage toward 80%; E2E ([E2E-PLAN.md](../testing/E2E-PLAN.md)); more UI tests on emulator CI. -1. **WebRTC Full Library Integration** - - Integrate actual WebRTC library calls - - Implement signaling server - - Complete audio/video track setup +### External or legal gates -2. **Backend API Integration** - - Connect SyncAPI to actual backend - - Implement Retrofit interfaces - - Add authentication headers +- Live NCIC/III, ATF eTrace, eIDAS QTSP, full AS4 partner CPA, Knox binary, shipped Xcode iOS app, production CORS/hosting for web scaffold. -3. **External API Integrations** (Requires Approval) - - NCIC API integration (CJIS approval required) - - ATF eTrace API (federal approval required) - - eIDAS QTSP integration (provider selection required) +### Maintenance -### Medium Priority - -1. **Digital Signature Full Implementation** - - BouncyCastle integration - - Signature generation/verification - - Certificate chain validation - -2. **XML Security** - - Apache Santuario integration - - XMLDSig implementation - - XMLEnc implementation - -3. **Certificate Revocation** - - OCSP client - - CRL parsing - - Revocation checking - -### Low Priority - -1. **Additional Test Coverage** - - More unit tests for remaining modules - - Integration tests - - UI tests - - End-to-end tests - - Target: 80%+ coverage - -2. **Data Serialization** - - Implement JSON serialization (Jackson/Gson) - - Add data validation - - Implement versioning +- [COMPLIANCE_MATRIX.md](../reference/COMPLIANCE_MATRIX.md) aligned with this tree as of 2026-03-24; refresh after large feature drops. +- **Android 16:** When upgrading AGP, follow [ANDROID-16-TARGET.md](../reference/ANDROID-16-TARGET.md). --- @@ -282,9 +245,9 @@ For detailed compliance information, see: | Version | Date | Changes | |---------|------|---------| | 1.0 | 2024-12-20 | Consolidated IMPLEMENTATION_COMPLETE.md and IMPLEMENTATION_STATUS.md, added table of contents, cross-references, and current status | +| 1.1 | 2026-03-24 | Remaining Work aligned with TASKS/TODO; Retrofit/sync corrections; compliance matrix refresh reference | --- -**Last Updated:** 2024-12-20 -**Status:** All Implementation Frameworks Complete +**Last Updated:** 2026-03-24 **Next Review:** Quarterly diff --git a/modules/atf/build.gradle.kts b/modules/atf/build.gradle.kts index 25f5e67..be65cbf 100644 --- a/modules/atf/build.gradle.kts +++ b/modules/atf/build.gradle.kts @@ -48,6 +48,11 @@ dependencies { implementation(Dependencies.composeUi) implementation(Dependencies.composeMaterial3) implementation(Dependencies.androidxCoreKtx) + implementation(Dependencies.androidxLifecycleRuntimeKtx) + implementation(Dependencies.androidxLifecycleViewmodelKtx) + implementation(Dependencies.androidxLifecycleViewmodelCompose) + implementation(Dependencies.androidxLifecycleRuntimeCompose) + implementation(Dependencies.hiltNavigationCompose) implementation(Dependencies.hiltAndroid) kapt(Dependencies.hiltAndroidCompiler) diff --git a/modules/atf/src/main/java/com/smoa/modules/atf/ATFModule.kt b/modules/atf/src/main/java/com/smoa/modules/atf/ATFModule.kt index a561b90..a07f30b 100644 --- a/modules/atf/src/main/java/com/smoa/modules/atf/ATFModule.kt +++ b/modules/atf/src/main/java/com/smoa/modules/atf/ATFModule.kt @@ -1,25 +1,83 @@ package com.smoa.modules.atf +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smoa.modules.atf.ui.AtfViewModel @Composable -fun ATFModule(modifier: Modifier = Modifier) { +fun ATFModule( + modifier: Modifier = Modifier, + viewModel: AtfViewModel = hiltViewModel() +) { + var tab by rememberSaveable { mutableIntStateOf(0) } + var serial by rememberSaveable { mutableStateOf("SN-DEMO-001") } + var name by rememberSaveable { mutableStateOf("Alex Demo") } + val msg by viewModel.message.collectAsStateWithLifecycle() + Column( modifier = modifier .fillMaxSize() - .padding(16.dp) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( - text = "ATF Forms", - style = MaterialTheme.typography.headlineMedium - ) + Text("ATF forms", style = MaterialTheme.typography.headlineSmall) + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("4473") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Form 1") }) + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Form 4") }) + } + when (tab) { + 0 -> { + OutlinedTextField( + serial, + { serial = it }, + label = { Text("Firearm serial") }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + name, + { name = it }, + label = { Text("Transferee name") }, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { viewModel.submit4473Demo(serial.trim(), name.trim()) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Validate & submit (simulated eTrace)") + } + } + 1 -> Text( + "NFA Form 1 (manufacture) — data models and validation live in domain; wire eForms when approved.", + style = MaterialTheme.typography.bodyMedium + ) + else -> Text( + "NFA Form 4 (transfer) — data models and validation live in domain; wire eForms when approved.", + style = MaterialTheme.typography.bodyMedium + ) + } + msg?.let { Text(it, style = MaterialTheme.typography.bodySmall) } } } - diff --git a/modules/atf/src/main/java/com/smoa/modules/atf/ui/AtfViewModel.kt b/modules/atf/src/main/java/com/smoa/modules/atf/ui/AtfViewModel.kt new file mode 100644 index 0000000..59d4d34 --- /dev/null +++ b/modules/atf/src/main/java/com/smoa/modules/atf/ui/AtfViewModel.kt @@ -0,0 +1,60 @@ +package com.smoa.modules.atf.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smoa.modules.atf.domain.ATFForm4473 +import com.smoa.modules.atf.domain.ATFService +import com.smoa.modules.atf.domain.FirearmType +import com.smoa.modules.atf.domain.FormStatus +import com.smoa.modules.atf.domain.PersonInfo +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Date +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class AtfViewModel @Inject constructor( + private val atfService: ATFService +) : ViewModel() { + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + fun submit4473Demo(serial: String, transfereeName: String) { + viewModelScope.launch { + val dob = Date(631152000000L) // 1990-ish + val person = PersonInfo( + name = transfereeName, + address = "100 Demo St", + city = "Washington", + state = "DC", + zipCode = "20001", + dateOfBirth = dob, + socialSecurityNumber = "1234" + ) + val form = ATFForm4473( + formId = UUID.randomUUID().toString(), + transactionDate = Date(), + firearmManufacturer = "Demo Arms", + firearmModel = "M-1", + firearmSerialNumber = serial, + firearmCaliber = "9mm", + firearmType = FirearmType.HANDGUN, + transfereeInfo = person, + transferorInfo = person.copy(name = "Demo FFL"), + nicsCheckNumber = "NICS-DEMO", + nicsCheckDate = Date(), + signatures = emptyList(), + status = FormStatus.DRAFT + ) + atfService.submitForm4473(form).fold( + onSuccess = { _message.value = "Submitted ${it.submissionId} (${it.status})" }, + onFailure = { _message.value = it.message ?: "Submit failed" } + ) + } + } +} diff --git a/modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheMerger.kt b/modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheMerger.kt index a18947b..402319a 100644 --- a/modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheMerger.kt +++ b/modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheMerger.kt @@ -2,6 +2,7 @@ package com.smoa.modules.credentials.data import com.google.gson.Gson import com.google.gson.reflect.TypeToken +import com.smoa.core.common.Result import com.smoa.core.common.sync.CredentialCacheSyncPort import com.smoa.core.common.sync.CredentialConflictRemoteJson import com.smoa.core.common.sync.CredentialSyncRequestDto @@ -73,6 +74,21 @@ class CredentialCacheMerger @Inject constructor( } } + override suspend fun hydrateMissingPayloads( + pullCredentialDetail: suspend (String) -> Result + ) { + withContext(Dispatchers.IO) { + for (row in dao.getAll()) { + if (row.payloadJson.isNullOrBlank()) { + when (val r = pullCredentialDetail(row.credentialId)) { + is Result.Success -> mergeDetailJson(r.data) + is Result.Error, Result.Loading -> Unit + } + } + } + } + } + override suspend fun upsertAfterCredentialSync(data: Any, serverTimestamp: Long, itemId: String) { withContext(Dispatchers.IO) { val dto = when (data) { diff --git a/modules/credentials/src/main/java/com/smoa/modules/credentials/ui/CredentialsViewModel.kt b/modules/credentials/src/main/java/com/smoa/modules/credentials/ui/CredentialsViewModel.kt index 1e2c0f8..65a003a 100644 --- a/modules/credentials/src/main/java/com/smoa/modules/credentials/ui/CredentialsViewModel.kt +++ b/modules/credentials/src/main/java/com/smoa/modules/credentials/ui/CredentialsViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.smoa.core.common.ConnectivityManager import com.smoa.core.common.PullAPI -import com.smoa.core.common.Result import com.smoa.core.common.SyncItem import com.smoa.core.common.SyncItemType import com.smoa.core.common.SyncOperation @@ -42,29 +41,18 @@ class CredentialsViewModel @Inject constructor( _selectedId.value = id } - fun hydrateMissingPayloads() { - viewModelScope.launch(Dispatchers.IO) { - hydratePayloadsIfMissing() - } - } - fun refreshFromServer() { viewModelScope.launch(Dispatchers.IO) { if (!connectivityManager.isOnline()) return@launch syncService.startSync() - hydratePayloadsIfMissing() } } - private suspend fun hydratePayloadsIfMissing() { - if (!connectivityManager.isOnline()) return - for (row in dao.getAll()) { - if (row.payloadJson.isNullOrBlank()) { - when (val r = pullAPI.pullCredentialDetail(row.credentialId)) { - is Result.Success -> merger.mergeDetailJson(r.data) - else -> Unit - } - } + /** Re-fetch payloads for cached rows that only have list metadata (e.g. after offline period). */ + fun hydrateMissingPayloads() { + viewModelScope.launch(Dispatchers.IO) { + if (!connectivityManager.isOnline()) return@launch + merger.hydrateMissingPayloads { pullAPI.pullCredentialDetail(it) } } } diff --git a/modules/credentials/src/test/java/com/smoa/modules/credentials/data/CredentialCacheMergerTest.kt b/modules/credentials/src/test/java/com/smoa/modules/credentials/data/CredentialCacheMergerTest.kt index a0c0c5c..1f931d3 100644 --- a/modules/credentials/src/test/java/com/smoa/modules/credentials/data/CredentialCacheMergerTest.kt +++ b/modules/credentials/src/test/java/com/smoa/modules/credentials/data/CredentialCacheMergerTest.kt @@ -1,6 +1,7 @@ package com.smoa.modules.credentials.data import com.google.gson.Gson +import com.smoa.core.common.Result import com.smoa.core.common.sync.CredentialSyncRequestDto import io.mockk.coEvery import io.mockk.coVerify @@ -9,6 +10,7 @@ import io.mockk.slot import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class CredentialCacheMergerTest { @@ -120,6 +122,34 @@ class CredentialCacheMergerTest { assertEquals("x", slot.captured.credentialId) } + @Test + fun `hydrateMissingPayloads pulls detail when payload empty`() = runTest { + val row = CredentialCacheEntity( + credentialId = "c1", + holderId = "h", + credentialType = "T", + issuer = null, + issuedAt = null, + expiresAt = null, + payloadJson = null, + updatedAt = 1L + ) + coEvery { dao.getAll() } returns listOf(row) + val detailJson = + """{"credentialId":"c1","holderId":"h","credentialType":"T","issuer":null,"issuedAt":1,"expiresAt":2,"payloadJson":"{\"x\":1}","updatedAt":3}""" + .toByteArray(Charsets.UTF_8) + + merger.hydrateMissingPayloads { id -> + assertEquals("c1", id) + Result.Success(detailJson) + } + + val slot = slot() + coVerify { dao.upsert(capture(slot)) } + assertEquals("c1", slot.captured.credentialId) + assertTrue(slot.captured.payloadJson!!.contains("x")) + } + @Test fun `mergeCredentialConflictRemoteData upserts server snapshot`() = runTest { val map = mapOf( diff --git a/modules/evidence/build.gradle.kts b/modules/evidence/build.gradle.kts index eab52c2..67da754 100644 --- a/modules/evidence/build.gradle.kts +++ b/modules/evidence/build.gradle.kts @@ -50,6 +50,10 @@ dependencies { implementation(Dependencies.composeMaterial3) implementation(Dependencies.androidxCoreKtx) implementation(Dependencies.androidxLifecycleRuntimeKtx) + implementation(Dependencies.androidxLifecycleViewmodelKtx) + implementation(Dependencies.androidxLifecycleViewmodelCompose) + implementation(Dependencies.androidxLifecycleRuntimeCompose) + implementation(Dependencies.hiltNavigationCompose) implementation(Dependencies.hiltAndroid) kapt(Dependencies.hiltAndroidCompiler) diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/data/EvidenceDao.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/data/EvidenceDao.kt index 8c42963..cceeaa8 100644 --- a/modules/evidence/src/main/java/com/smoa/modules/evidence/data/EvidenceDao.kt +++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/data/EvidenceDao.kt @@ -42,6 +42,9 @@ interface CustodyTransferDao { @Query("SELECT * FROM custody_transfers WHERE evidenceId = :evidenceId ORDER BY timestamp ASC") fun getChainOfCustody(evidenceId: String): Flow> + @Query("SELECT * FROM custody_transfers ORDER BY timestamp ASC") + fun observeAllTransfers(): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTransfer(transfer: CustodyTransferEntity) diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceRepository.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceRepository.kt index 0465767..e501561 100644 --- a/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceRepository.kt +++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceRepository.kt @@ -5,6 +5,7 @@ import com.smoa.modules.evidence.data.EvidenceDao import com.smoa.modules.evidence.data.EvidenceEntity import com.smoa.modules.evidence.data.CustodyTransferEntity import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import java.util.Date import java.util.UUID @@ -25,14 +26,20 @@ class EvidenceRepository @Inject constructor( suspend fun getEvidenceById(evidenceId: String): Evidence? { val entity = evidenceDao.getEvidenceById(evidenceId) ?: return null - val transfers = custodyTransferDao.getChainOfCustody(evidenceId) - // Convert Flow to List (simplified - in production use proper async handling) - return entity.toDomain(emptyList()) // Will need to load transfers separately + return entity.toDomain(emptyList()) } fun getEvidenceByCase(caseNumber: String): Flow> { - return evidenceDao.getEvidenceByCase(caseNumber).map { entities -> - entities.map { it.toDomain(emptyList()) } + return combine( + evidenceDao.getEvidenceByCase(caseNumber), + custodyTransferDao.observeAllTransfers() + ) { entities, transfers -> + entities.map { e -> + val chain = transfers + .filter { it.evidenceId == e.evidenceId } + .map { it.toDomain() } + e.toDomain(chain) + } } } @@ -40,8 +47,8 @@ class EvidenceRepository @Inject constructor( evidenceDao.insertEvidence(evidence.toEntity()) } - suspend fun addCustodyTransfer(transfer: CustodyTransfer) { - custodyTransferDao.insertTransfer(transfer.toEntity()) + suspend fun addCustodyTransfer(evidenceId: String, transfer: CustodyTransfer) { + custodyTransferDao.insertTransfer(transfer.toEntity(evidenceId)) } fun getChainOfCustody(evidenceId: String): Flow> { @@ -104,10 +111,10 @@ private fun CustodyTransferEntity.toDomain(): CustodyTransfer { ) } -private fun CustodyTransfer.toEntity(): CustodyTransferEntity { +private fun CustodyTransfer.toEntity(evidenceId: String): CustodyTransferEntity { return CustodyTransferEntity( transferId = transferId, - evidenceId = "", // Should be set by caller + evidenceId = evidenceId, timestamp = timestamp, fromCustodian = fromCustodian, toCustodian = toCustodian, diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceService.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceService.kt index e46911a..fd17f10 100644 --- a/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceService.kt +++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceService.kt @@ -75,7 +75,7 @@ class EvidenceService @Inject constructor( ) // In production, update evidence currentCustodian - repository.addCustodyTransfer(transfer) + repository.addCustodyTransfer(evidenceId, transfer) auditLogger.logEvent( AuditEventType.CREDENTIAL_ACCESS, userId = fromCustodian, diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceDetailScreen.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceDetailScreen.kt new file mode 100644 index 0000000..fe31c90 --- /dev/null +++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceDetailScreen.kt @@ -0,0 +1,73 @@ +package com.smoa.modules.evidence.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.smoa.modules.evidence.domain.Evidence + +@Composable +fun EvidenceDetailScreen( + evidence: Evidence, + currentUserId: String, + onTransfer: (toCustodian: String) -> Unit, + modifier: Modifier = Modifier +) { + var toName by rememberSaveable { mutableStateOf("custodian-b") } + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(evidence.caseNumber, style = MaterialTheme.typography.headlineSmall) + Text(evidence.description, style = MaterialTheme.typography.bodyLarge) + Text( + "Type ${evidence.evidenceType.name} · Collected by ${evidence.collectedBy}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text("Current custodian: ${evidence.currentCustodian}", style = MaterialTheme.typography.titleSmall) + Text("Chain of custody (logged transfers)", style = MaterialTheme.typography.labelLarge) + if (evidence.chainOfCustody.isEmpty()) { + Text("No transfers yet.", style = MaterialTheme.typography.bodySmall) + } else { + evidence.chainOfCustody.forEach { t -> + Text( + "→ ${t.timestamp} : ${t.fromCustodian} to ${t.toCustodian} (${t.reason})", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + OutlinedTextField( + value = toName, + onValueChange = { toName = it }, + label = { Text("Transfer to (user id)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { onTransfer(toName.trim().ifBlank { "custodian-b" }) }, + enabled = currentUserId.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + Text("Log custody transfer") + } + } +} diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceListScreen.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceListScreen.kt index 36b3d30..e8b0e05 100644 --- a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceListScreen.kt +++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceListScreen.kt @@ -1,25 +1,75 @@ package com.smoa.modules.evidence.ui +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.smoa.modules.evidence.domain.Evidence @Composable -fun EvidenceListScreen(modifier: Modifier = Modifier) { - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp) +fun EvidenceListScreen( + items: List, + onSelect: (Evidence) -> Unit, + modifier: Modifier = Modifier +) { + if (items.isEmpty()) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + "No evidence records", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Add a sample item to begin the chain-of-custody workflow.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + return + } + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = "Evidence", - style = MaterialTheme.typography.headlineMedium - ) + items(items, key = { it.evidenceId }) { e -> + Card( + Modifier + .fillMaxWidth() + .clickable { onSelect(e) }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(Modifier.padding(16.dp)) { + Text(e.caseNumber, style = MaterialTheme.typography.titleMedium) + Text( + "${e.evidenceType.name} · ${e.description}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Custodian: ${e.currentCustodian}", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } } } - diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceModule.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceModule.kt index 1ab09c5..67fa8f7 100644 --- a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceModule.kt +++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceModule.kt @@ -1,25 +1,91 @@ package com.smoa.modules.evidence.ui -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun EvidenceModule(modifier: Modifier = Modifier) { - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp) - ) { - Text( - text = "Evidence Chain of Custody", - style = MaterialTheme.typography.headlineMedium - ) +fun EvidenceModule( + userId: String, + modifier: Modifier = Modifier, + viewModel: EvidenceViewModel = hiltViewModel() +) { + val items by viewModel.evidenceItems.collectAsStateWithLifecycle() + var selectedId: String? by remember { mutableStateOf(null) } + val selected = selectedId?.let { id -> items.firstOrNull { it.evidenceId == id } } + val snack = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snack) }, + topBar = { + TopAppBar( + title = { Text(if (selected == null) "Evidence" else "Evidence detail") }, + navigationIcon = { + if (selected != null) { + IconButton(onClick = { selectedId = null }) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + } + ) + }, + floatingActionButton = { + if (selectedId == null) { + FloatingActionButton( + onClick = { + viewModel.createSample(userId) { err -> + scope.launch { + snack.showSnackbar(err ?: "Sample evidence created") + } + } + } + ) { + Icon(Icons.Default.Add, contentDescription = "Add sample") + } + } + } + ) { padding -> + when (val e = selected) { + null -> EvidenceListScreen( + items = items, + onSelect = { selectedId = it.evidenceId }, + modifier = Modifier.padding(padding) + ) + else -> EvidenceDetailScreen( + evidence = e, + currentUserId = userId, + onTransfer = { to -> + viewModel.transferTo(e.evidenceId, userId, to) { err -> + scope.launch { + snack.showSnackbar(err ?: "Transfer logged") + } + } + }, + modifier = Modifier.padding(padding) + ) + } } } - diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceViewModel.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceViewModel.kt new file mode 100644 index 0000000..4ad2590 --- /dev/null +++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceViewModel.kt @@ -0,0 +1,61 @@ +package com.smoa.modules.evidence.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smoa.modules.evidence.domain.DigitalSignature +import com.smoa.modules.evidence.domain.Evidence +import com.smoa.modules.evidence.domain.EvidenceService +import com.smoa.modules.evidence.domain.EvidenceType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.Date +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class EvidenceViewModel @Inject constructor( + private val evidenceService: EvidenceService +) : ViewModel() { + + val evidenceItems: StateFlow> = evidenceService.getAllEvidence() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun createSample(userId: String, onResult: (String?) -> Unit) { + viewModelScope.launch { + val r = evidenceService.createEvidence( + caseNumber = "DEMO-${System.currentTimeMillis() % 100000}", + description = "Sample evidence item for demonstration.", + evidenceType = EvidenceType.DOCUMENT, + collectionLocation = "Lab / field office", + collectionMethod = "Digital import", + collectedBy = userId, + ) + onResult(r.exceptionOrNull()?.message) + } + } + + fun transferTo(evidenceId: String, fromUser: String, toUser: String, onResult: (String?) -> Unit) { + viewModelScope.launch { + val sig = DigitalSignature( + signatureId = UUID.randomUUID().toString(), + signerId = fromUser, + signerName = fromUser, + signatureDate = Date(), + signatureData = byteArrayOf() + ) + val r = evidenceService.transferCustody( + evidenceId = evidenceId, + fromCustodian = fromUser, + toCustodian = toUser, + reason = "Custody transfer (demo)", + evidenceCondition = "Sealed / intact", + signature = sig, + notes = null + ) + onResult(r.exceptionOrNull()?.message) + } + } +} diff --git a/modules/intelligence/build.gradle.kts b/modules/intelligence/build.gradle.kts index 625cb46..f513736 100644 --- a/modules/intelligence/build.gradle.kts +++ b/modules/intelligence/build.gradle.kts @@ -48,6 +48,11 @@ dependencies { implementation(Dependencies.composeUi) implementation(Dependencies.composeMaterial3) implementation(Dependencies.androidxCoreKtx) + implementation(Dependencies.androidxLifecycleRuntimeKtx) + implementation(Dependencies.androidxLifecycleViewmodelKtx) + implementation(Dependencies.androidxLifecycleViewmodelCompose) + implementation(Dependencies.androidxLifecycleRuntimeCompose) + implementation(Dependencies.hiltNavigationCompose) implementation(Dependencies.hiltAndroid) kapt(Dependencies.hiltAndroidCompiler) diff --git a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/IntelligenceModule.kt b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/IntelligenceModule.kt index 4396aa4..a02085d 100644 --- a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/IntelligenceModule.kt +++ b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/IntelligenceModule.kt @@ -1,25 +1,80 @@ package com.smoa.modules.intelligence +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smoa.modules.intelligence.ui.IntelligenceViewModel @Composable -fun IntelligenceModule(modifier: Modifier = Modifier) { +fun IntelligenceModule( + userId: String, + modifier: Modifier = Modifier, + viewModel: IntelligenceViewModel = hiltViewModel() +) { + var compartmentId by rememberSaveable { mutableStateOf("") } + val lines by viewModel.lines.collectAsStateWithLifecycle() + Column( modifier = modifier .fillMaxSize() - .padding(16.dp) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) ) { + Text("Intelligence (MLS)", style = MaterialTheme.typography.headlineSmall) Text( - text = "Intelligence Operations", - style = MaterialTheme.typography.headlineMedium + "Compartments, need-to-know, and protected sources.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + Button(onClick = { viewModel.registerDemoCompartment() }, modifier = Modifier.fillMaxWidth()) { + Text("Register demo compartment") + } + OutlinedTextField( + compartmentId, + { compartmentId = it }, + label = { Text("Compartment id") }, + placeholder = { Text("Use id from list below") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { viewModel.grantDemoAccess(userId, compartmentId.trim()) }, + enabled = compartmentId.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + Text("Grant access + NTK (demo)") + } + Button( + onClick = { viewModel.checkAccess(userId, compartmentId.trim()) }, + enabled = compartmentId.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + Text("Check access") + } + Button(onClick = { viewModel.createDemoSource(userId) }, modifier = Modifier.fillMaxWidth()) { + Text("Create demo protected source") + } + Text("Compartments", style = MaterialTheme.typography.labelLarge) + lines.forEach { line -> + Text(line, style = MaterialTheme.typography.bodySmall) + } } } - diff --git a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/CompartmentManager.kt b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/CompartmentManager.kt index b2721c1..bc65208 100644 --- a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/CompartmentManager.kt +++ b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/CompartmentManager.kt @@ -19,6 +19,9 @@ class CompartmentManager @Inject constructor() { fun registerCompartment(compartment: Compartment) { compartments[compartment.compartmentId] = compartment } + + /** Snapshot for UI / diagnostics (in-memory only). */ + fun getRegisteredCompartments(): List = compartments.values.toList() /** * Check if user has access to compartment. diff --git a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/IntelligenceService.kt b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/IntelligenceService.kt index 2928844..cbe7955 100644 --- a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/IntelligenceService.kt +++ b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/IntelligenceService.kt @@ -36,6 +36,26 @@ class IntelligenceService @Inject constructor( return compartmentManager.hasAccess(userId, compartmentId) && compartmentManager.hasNeedToKnow(userId, compartmentId) } + + fun listRegisteredCompartments(): List = + compartmentManager.getRegisteredCompartments() + + fun grantCompartmentAccess(userId: String, compartmentId: String) { + compartmentManager.grantAccess(userId, compartmentId) + } + + fun authorizeNeedToKnow(userId: String, compartmentId: String) { + compartmentManager.addNeedToKnow( + NeedToKnow( + compartmentId = compartmentId, + userId = userId, + justification = "Operational authorization", + authorizedBy = "security_officer", + authorizationDate = Date(), + expirationDate = null + ) + ) + } /** * Create protected source. diff --git a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/ui/IntelligenceViewModel.kt b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/ui/IntelligenceViewModel.kt new file mode 100644 index 0000000..4575b0e --- /dev/null +++ b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/ui/IntelligenceViewModel.kt @@ -0,0 +1,77 @@ +package com.smoa.modules.intelligence.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smoa.modules.intelligence.domain.AccessLevel +import com.smoa.modules.intelligence.domain.Compartment +import com.smoa.modules.intelligence.domain.IntelligenceService +import com.smoa.modules.intelligence.domain.ProtectionLevel +import com.smoa.modules.intelligence.domain.SourceType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class IntelligenceViewModel @Inject constructor( + private val intelligenceService: IntelligenceService +) : ViewModel() { + + private val _lines = MutableStateFlow>(emptyList()) + val lines: StateFlow> = _lines.asStateFlow() + + private fun refresh() { + _lines.value = intelligenceService.listRegisteredCompartments() + .map { "${it.compartmentId} · ${it.name} (${it.accessLevel.name})" } + } + + init { + refresh() + } + + fun registerDemoCompartment() { + viewModelScope.launch { + val id = "CMP-${UUID.randomUUID().toString().take(8).uppercase()}" + intelligenceService.registerCompartment( + Compartment( + compartmentId = id, + name = "Demo compartment", + description = "MLS demo registration", + accessLevel = AccessLevel.SECRET, + controllingAgency = "DEMO-IC", + authorizedPersonnel = emptyList() + ) + ) + refresh() + } + } + + fun grantDemoAccess(userId: String, compartmentId: String) { + intelligenceService.grantCompartmentAccess(userId, compartmentId) + intelligenceService.authorizeNeedToKnow(userId, compartmentId) + _lines.value = _lines.value + "Access + NTK granted for $userId on $compartmentId" + } + + fun checkAccess(userId: String, compartmentId: String) { + val ok = intelligenceService.checkCompartmentAccess(userId, compartmentId) + _lines.value = _lines.value + "Access check $compartmentId: $ok" + } + + fun createDemoSource(userId: String) { + viewModelScope.launch { + intelligenceService.createProtectedSource( + sourceType = SourceType.HUMAN_INTELLIGENCE, + codename = "DEMO-SRC", + description = "Demonstration protected source record", + protectionLevel = ProtectionLevel.HIGHLY_SENSITIVE, + authorizedHandlers = listOf(userId) + ).fold( + onSuccess = { s -> _lines.value = _lines.value + "Source ${s.sourceId} created" }, + onFailure = { e -> _lines.value = _lines.value + (e.message ?: "source error") } + ) + } + } +} diff --git a/modules/judicial/build.gradle.kts b/modules/judicial/build.gradle.kts index e4a02a5..a6aed78 100644 --- a/modules/judicial/build.gradle.kts +++ b/modules/judicial/build.gradle.kts @@ -50,6 +50,11 @@ dependencies { implementation(Dependencies.composeUi) implementation(Dependencies.composeMaterial3) implementation(Dependencies.androidxCoreKtx) + implementation(Dependencies.androidxLifecycleRuntimeKtx) + implementation(Dependencies.androidxLifecycleViewmodelKtx) + implementation(Dependencies.androidxLifecycleViewmodelCompose) + implementation(Dependencies.androidxLifecycleRuntimeCompose) + implementation(Dependencies.hiltNavigationCompose) implementation(Dependencies.hiltAndroid) kapt(Dependencies.hiltAndroidCompiler) diff --git a/modules/judicial/src/main/java/com/smoa/modules/judicial/JudicialModule.kt b/modules/judicial/src/main/java/com/smoa/modules/judicial/JudicialModule.kt index 18004c5..bc1cacd 100644 --- a/modules/judicial/src/main/java/com/smoa/modules/judicial/JudicialModule.kt +++ b/modules/judicial/src/main/java/com/smoa/modules/judicial/JudicialModule.kt @@ -1,25 +1,54 @@ package com.smoa.modules.judicial +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smoa.modules.judicial.ui.JudicialViewModel @Composable -fun JudicialModule(modifier: Modifier = Modifier) { +fun JudicialModule( + modifier: Modifier = Modifier, + viewModel: JudicialViewModel = hiltViewModel() +) { + var caseNo by rememberSaveable { mutableStateOf("CR-2025-DEMO") } + var title by rememberSaveable { mutableStateOf("Demonstration court order") } + val summary by viewModel.summary.collectAsStateWithLifecycle() + Column( modifier = modifier .fillMaxSize() - .padding(16.dp) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + Text("Judicial", style = MaterialTheme.typography.headlineSmall) Text( - text = "Judicial Operations", - style = MaterialTheme.typography.headlineMedium + "Court orders, case files, and subpoenas (domain services).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + OutlinedTextField(caseNo, { caseNo = it }, label = { Text("Case number") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(title, { title = it }, label = { Text("Title") }, modifier = Modifier.fillMaxWidth()) + Button( + onClick = { viewModel.createDemoOrder(caseNo.trim(), title.trim()) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Create demo court order") + } + summary?.let { Text(it, style = MaterialTheme.typography.bodySmall) } } } - diff --git a/modules/judicial/src/main/java/com/smoa/modules/judicial/ui/JudicialViewModel.kt b/modules/judicial/src/main/java/com/smoa/modules/judicial/ui/JudicialViewModel.kt new file mode 100644 index 0000000..a4e95bf --- /dev/null +++ b/modules/judicial/src/main/java/com/smoa/modules/judicial/ui/JudicialViewModel.kt @@ -0,0 +1,40 @@ +package com.smoa.modules.judicial.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smoa.modules.judicial.domain.CourtOrderType +import com.smoa.modules.judicial.domain.JudicialService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class JudicialViewModel @Inject constructor( + private val judicialService: JudicialService +) : ViewModel() { + + private val _summary = MutableStateFlow(null) + val summary: StateFlow = _summary.asStateFlow() + + fun createDemoOrder(caseNumber: String, title: String) { + viewModelScope.launch { + judicialService.createCourtOrder( + courtName = "Demo District Court", + caseNumber = caseNumber, + orderType = CourtOrderType.COURT_ORDER, + title = title, + content = "Demonstration judicial order content.", + judgeName = "Hon. Demo Judge", + effectiveDate = Date(), + expirationDate = null + ).fold( + onSuccess = { o -> _summary.value = "${o.orderId} · ${o.caseNumber} · ${o.status.name}" }, + onFailure = { e -> _summary.value = e.message ?: "Failed" } + ) + } + } +} diff --git a/modules/military/build.gradle.kts b/modules/military/build.gradle.kts index ae76625..62e4331 100644 --- a/modules/military/build.gradle.kts +++ b/modules/military/build.gradle.kts @@ -49,6 +49,11 @@ dependencies { implementation(Dependencies.composeUi) implementation(Dependencies.composeMaterial3) implementation(Dependencies.androidxCoreKtx) + implementation(Dependencies.androidxLifecycleRuntimeKtx) + implementation(Dependencies.androidxLifecycleViewmodelKtx) + implementation(Dependencies.androidxLifecycleViewmodelCompose) + implementation(Dependencies.androidxLifecycleRuntimeCompose) + implementation(Dependencies.hiltNavigationCompose) implementation(Dependencies.hiltAndroid) kapt(Dependencies.hiltAndroidCompiler) diff --git a/modules/military/src/main/java/com/smoa/modules/military/MilitaryModule.kt b/modules/military/src/main/java/com/smoa/modules/military/MilitaryModule.kt index 8bb0b01..5178f77 100644 --- a/modules/military/src/main/java/com/smoa/modules/military/MilitaryModule.kt +++ b/modules/military/src/main/java/com/smoa/modules/military/MilitaryModule.kt @@ -1,25 +1,54 @@ package com.smoa.modules.military +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smoa.modules.military.ui.MilitaryViewModel @Composable -fun MilitaryModule(modifier: Modifier = Modifier) { +fun MilitaryModule( + modifier: Modifier = Modifier, + viewModel: MilitaryViewModel = hiltViewModel() +) { + var last by rememberSaveable { mutableStateOf("Doe") } + var first by rememberSaveable { mutableStateOf("Jane") } + val summary by viewModel.credentialSummary.collectAsStateWithLifecycle() + Column( modifier = modifier .fillMaxSize() - .padding(16.dp) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + Text("Military credentials", style = MaterialTheme.typography.headlineSmall) Text( - text = "Military Operations", - style = MaterialTheme.typography.headlineMedium + "MIL-STD aligned credential record (demo issuance).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + OutlinedTextField(last, { last = it }, label = { Text("Last name") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(first, { first = it }, label = { Text("First name") }, modifier = Modifier.fillMaxWidth()) + Button( + onClick = { viewModel.createDemoCredential(last.trim(), first.trim()) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Issue demo credential") + } + summary?.let { Text(it, style = MaterialTheme.typography.bodySmall) } } } - diff --git a/modules/military/src/main/java/com/smoa/modules/military/ui/MilitaryViewModel.kt b/modules/military/src/main/java/com/smoa/modules/military/ui/MilitaryViewModel.kt new file mode 100644 index 0000000..d8116e0 --- /dev/null +++ b/modules/military/src/main/java/com/smoa/modules/military/ui/MilitaryViewModel.kt @@ -0,0 +1,43 @@ +package com.smoa.modules.military.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smoa.modules.military.domain.ClearanceLevel +import com.smoa.modules.military.domain.MilitaryService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class MilitaryViewModel @Inject constructor( + private val militaryService: MilitaryService +) : ViewModel() { + + private val _credentialSummary = MutableStateFlow(null) + val credentialSummary: StateFlow = _credentialSummary.asStateFlow() + + fun createDemoCredential(lastName: String, firstName: String) { + viewModelScope.launch { + militaryService.createMilitaryCredential( + serviceCode = "USA", + rank = "CPT", + lastName = lastName, + firstName = firstName, + socialSecurityNumber = "XXX-XX-0000", + dateOfBirth = Date(631152000000L), + expirationDate = Date(System.currentTimeMillis() + 86400000L * 365 * 5), + unit = "1-DEMO", + clearanceLevel = ClearanceLevel.SECRET + ).fold( + onSuccess = { c -> + _credentialSummary.value = "${c.credentialId} · ${c.serviceCode} ${c.rank} ${c.lastName}" + }, + onFailure = { e -> _credentialSummary.value = e.message ?: "Failed" } + ) + } + } +} diff --git a/modules/ncic/build.gradle.kts b/modules/ncic/build.gradle.kts index f32e734..b6f9636 100644 --- a/modules/ncic/build.gradle.kts +++ b/modules/ncic/build.gradle.kts @@ -48,6 +48,11 @@ dependencies { implementation(Dependencies.composeUi) implementation(Dependencies.composeMaterial3) implementation(Dependencies.androidxCoreKtx) + implementation(Dependencies.androidxLifecycleRuntimeKtx) + implementation(Dependencies.androidxLifecycleViewmodelKtx) + implementation(Dependencies.androidxLifecycleViewmodelCompose) + implementation(Dependencies.androidxLifecycleRuntimeCompose) + implementation(Dependencies.hiltNavigationCompose) implementation(Dependencies.hiltAndroid) kapt(Dependencies.hiltAndroidCompiler) diff --git a/modules/ncic/src/main/java/com/smoa/modules/ncic/NCICModule.kt b/modules/ncic/src/main/java/com/smoa/modules/ncic/NCICModule.kt index d1f513c..427b94c 100644 --- a/modules/ncic/src/main/java/com/smoa/modules/ncic/NCICModule.kt +++ b/modules/ncic/src/main/java/com/smoa/modules/ncic/NCICModule.kt @@ -1,25 +1,25 @@ package com.smoa.modules.ncic -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smoa.modules.ncic.ui.NCICQueryScreen +import com.smoa.modules.ncic.ui.NcicViewModel @Composable -fun NCICModule(modifier: Modifier = Modifier) { - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp) - ) { - Text( - text = "NCIC/III Integration", - style = MaterialTheme.typography.headlineMedium - ) - } +fun NCICModule( + userId: String, + modifier: Modifier = Modifier, + viewModel: NcicViewModel = hiltViewModel() +) { + val result by viewModel.result.collectAsStateWithLifecycle() + NCICQueryScreen( + operatorId = userId, + onRunQuery = { ori, ucn, type -> viewModel.runQuery(ori, ucn, type, userId) }, + resultText = result, + modifier = modifier.fillMaxSize() + ) } - diff --git a/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NCICQueryScreen.kt b/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NCICQueryScreen.kt index bc6b098..fd4356a 100644 --- a/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NCICQueryScreen.kt +++ b/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NCICQueryScreen.kt @@ -1,25 +1,88 @@ package com.smoa.modules.ncic.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Alignment +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.smoa.modules.ncic.domain.NCICQueryType @Composable -fun NCICQueryScreen(modifier: Modifier = Modifier) { +fun NCICQueryScreen( + operatorId: String, + onRunQuery: (ori: String, ucn: String, type: NCICQueryType) -> Unit, + resultText: String?, + modifier: Modifier = Modifier +) { + val types = NCICQueryType.entries + var typeIx by rememberSaveable { mutableIntStateOf(0) } + var ori by rememberSaveable { mutableStateOf("AAA123456") } + var ucn by rememberSaveable { mutableStateOf("AAA123456250320ABCDEF") } + Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp) + modifier = modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + Text("NCIC / III", style = MaterialTheme.typography.headlineSmall) Text( - text = "NCIC Query", - style = MaterialTheme.typography.headlineMedium + "Live queries require CJIS connectivity and approval. This screen exercises ORI/UCN validation and audit logging.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( + "Operator: $operatorId", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + OutlinedTextField( + ori, + { ori = it.uppercase() }, + label = { Text("ORI (9 chars)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + ucn, + { ucn = it }, + label = { Text("UCN (15+ chars)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + RowTypeSelector(types[typeIx].name) { + typeIx = (typeIx + 1) % types.size + } + Button( + onClick = { onRunQuery(ori.trim(), ucn.trim(), types[typeIx]) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Execute (simulated)") + } + resultText?.let { Text(it, style = MaterialTheme.typography.bodySmall) } } } +@Composable +private fun RowTypeSelector(label: String, onChange: () -> Unit) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Query type: $label", style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onChange) { Text("Change") } + } +} diff --git a/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NcicViewModel.kt b/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NcicViewModel.kt new file mode 100644 index 0000000..6784756 --- /dev/null +++ b/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NcicViewModel.kt @@ -0,0 +1,40 @@ +package com.smoa.modules.ncic.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smoa.modules.ncic.domain.NCICQuery +import com.smoa.modules.ncic.domain.NCICQueryType +import com.smoa.modules.ncic.domain.NCICService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class NcicViewModel @Inject constructor( + private val ncicService: NCICService +) : ViewModel() { + + private val _result = MutableStateFlow(null) + val result: StateFlow = _result.asStateFlow() + + fun runQuery(ori: String, ucn: String, type: NCICQueryType, operatorId: String) { + viewModelScope.launch { + val q = NCICQuery( + ori = ori.uppercase(), + ucn = ucn, + queryType = type, + searchCriteria = mapOf("demo" to "true"), + operatorId = operatorId + ) + ncicService.executeQuery(q).fold( + onSuccess = { r -> + _result.value = "${r.responseCode.name}: ${r.message}" + }, + onFailure = { e -> _result.value = e.message ?: "Query failed" } + ) + } + } +} diff --git a/modules/orders/build.gradle.kts b/modules/orders/build.gradle.kts index 6bfe72d..f85d6c0 100644 --- a/modules/orders/build.gradle.kts +++ b/modules/orders/build.gradle.kts @@ -50,6 +50,10 @@ dependencies { implementation(Dependencies.composeMaterial3) implementation(Dependencies.androidxCoreKtx) implementation(Dependencies.androidxLifecycleRuntimeKtx) + implementation(Dependencies.androidxLifecycleViewmodelKtx) + implementation(Dependencies.androidxLifecycleViewmodelCompose) + implementation(Dependencies.androidxLifecycleRuntimeCompose) + implementation(Dependencies.hiltNavigationCompose) implementation(Dependencies.hiltAndroid) kapt(Dependencies.hiltAndroidCompiler) @@ -63,4 +67,8 @@ dependencies { implementation(Dependencies.coroutinesCore) implementation(Dependencies.coroutinesAndroid) + + testImplementation(Dependencies.junit) + testImplementation(Dependencies.coroutinesTest) + testImplementation(Dependencies.mockk) } diff --git a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderDetailScreen.kt b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderDetailScreen.kt index 2493e09..7572474 100644 --- a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderDetailScreen.kt +++ b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderDetailScreen.kt @@ -1,33 +1,50 @@ package com.smoa.modules.orders.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.smoa.modules.orders.domain.Order -/** - * Order detail screen for viewing individual order. - */ @Composable fun OrderDetailScreen( - orderId: String, + order: Order, + onAdvanceWorkflow: () -> Unit, modifier: Modifier = Modifier ) { Column( modifier = modifier .fillMaxSize() - .padding(16.dp) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + Text(order.title, style = MaterialTheme.typography.headlineSmall) Text( - text = "Order Details", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(bottom = 16.dp) + "Type: ${order.orderType.name} Status: ${order.status.name}", + style = MaterialTheme.typography.bodyMedium ) - // Order detail UI will be implemented here + Text("Content", style = MaterialTheme.typography.labelLarge) + Text(order.content, style = MaterialTheme.typography.bodyMedium) + Text( + "Jurisdiction: ${order.metadata.jurisdiction} Case: ${order.metadata.caseNumber ?: "—"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button( + onClick = onAdvanceWorkflow, + modifier = Modifier.fillMaxWidth() + ) { + Text("Advance workflow status") + } } } - diff --git a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderListScreen.kt b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderListScreen.kt index 3aa5fc7..2ab52be 100644 --- a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderListScreen.kt +++ b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderListScreen.kt @@ -1,32 +1,75 @@ package com.smoa.modules.orders.ui +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.smoa.modules.orders.domain.Order -/** - * Order list screen displaying all orders. - */ @Composable fun OrderListScreen( + orders: List, + onOrderClick: (Order) -> Unit, modifier: Modifier = Modifier ) { - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp) + if (orders.isEmpty()) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = "No orders yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Use the + action to create a sample order for demonstration.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + return + } + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = "Orders", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(bottom = 16.dp) - ) - // Order list UI will be implemented here + items(orders, key = { it.orderId }) { order -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onOrderClick(order) }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(Modifier.padding(16.dp)) { + Text(order.title, style = MaterialTheme.typography.titleMedium) + Text( + "${order.orderType.name} · ${order.status.name}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Issued by ${order.issuedBy}", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } } } - diff --git a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersModule.kt b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersModule.kt index dbb226d..278e2f3 100644 --- a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersModule.kt +++ b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersModule.kt @@ -1,31 +1,97 @@ package com.smoa.modules.orders.ui -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smoa.modules.orders.domain.Order +import kotlinx.coroutines.launch /** - * Orders module - Digital orders management system. + * Orders hub: list and detail backed by [OrderService] / Room. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun OrdersModule( - modifier: Modifier = Modifier + userId: String, + modifier: Modifier = Modifier, + viewModel: OrdersViewModel = hiltViewModel() ) { - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp) - ) { - Text( - text = "Orders Management", - style = MaterialTheme.typography.headlineMedium - ) - // Orders management UI will be implemented here + val orders by viewModel.orders.collectAsStateWithLifecycle() + var selectedId: String? by remember { mutableStateOf(null) } + val selected = selectedId?.let { id -> orders.firstOrNull { it.orderId == id } } + val snack = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snack) }, + topBar = { + TopAppBar( + title = { + Text(if (selected == null) "Orders" else "Order detail") + }, + navigationIcon = { + if (selected != null) { + IconButton(onClick = { selectedId = null }) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + } + ) + }, + floatingActionButton = { + if (selectedId == null) { + FloatingActionButton( + onClick = { + viewModel.createSampleOrder(userId) { err -> + scope.launch { + snack.showSnackbar(err ?: "Sample order created") + } + } + } + ) { + Icon(Icons.Default.Add, contentDescription = "Add sample order") + } + } + } + ) { padding -> + when (val o = selected) { + null -> OrderListScreen( + orders = orders, + onOrderClick = { selectedId = it.orderId }, + modifier = Modifier.padding(padding) + ) + else -> OrderDetailScreen( + order = o, + onAdvanceWorkflow = { + viewModel.advanceStatus(o.orderId, userId) { err -> + scope.launch { + snack.showSnackbar(err ?: "Status updated") + } + } + }, + modifier = Modifier.padding(padding) + ) + } } } - diff --git a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersViewModel.kt b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersViewModel.kt new file mode 100644 index 0000000..33e43c4 --- /dev/null +++ b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersViewModel.kt @@ -0,0 +1,77 @@ +package com.smoa.modules.orders.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smoa.core.common.Result +import com.smoa.modules.orders.domain.Order +import com.smoa.modules.orders.domain.OrderMetadata +import com.smoa.modules.orders.domain.OrderService +import com.smoa.modules.orders.domain.OrderStatus +import com.smoa.modules.orders.domain.OrderType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class OrdersViewModel @Inject constructor( + private val orderService: OrderService +) : ViewModel() { + + val orders: StateFlow> = orderService.getAllOrders() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun createSampleOrder(userId: String, onResult: (String?) -> Unit) { + viewModelScope.launch { + when ( + val r: Result = orderService.createOrder( + orderType = OrderType.ADMINISTRATIVE, + title = "Sample order", + content = "Demonstration order created from SMOA.", + issuedBy = userId, + issuedTo = null, + effectiveDate = Date(), + expirationDate = null, + metadata = OrderMetadata( + classification = null, + jurisdiction = "DEMO", + caseNumber = null, + keywords = listOf("sample") + ) + ) + ) { + is Result.Success -> onResult(null) + is Result.Error -> onResult(r.exception.message ?: "Error") + Result.Loading -> onResult(null) + } + } + } + + fun advanceStatus(orderId: String, userId: String, onResult: (String?) -> Unit) { + viewModelScope.launch { + val order = orders.value.firstOrNull { it.orderId == orderId } ?: run { + onResult("Order not found") + return@launch + } + val next = when (order.status) { + OrderStatus.DRAFT -> OrderStatus.PENDING_APPROVAL + OrderStatus.PENDING_APPROVAL -> OrderStatus.APPROVED + OrderStatus.APPROVED -> OrderStatus.ISSUED + OrderStatus.ISSUED -> OrderStatus.EXECUTED + else -> null + } + if (next == null) { + onResult("No further workflow step") + return@launch + } + when (val r = orderService.updateOrderStatus(orderId, next, userId)) { + is Result.Success -> onResult(null) + is Result.Error -> onResult(r.exception.message ?: "Error") + Result.Loading -> onResult(null) + } + } + } +} diff --git a/modules/orders/src/test/java/com/smoa/modules/orders/domain/OrderServiceTest.kt b/modules/orders/src/test/java/com/smoa/modules/orders/domain/OrderServiceTest.kt new file mode 100644 index 0000000..68312a3 --- /dev/null +++ b/modules/orders/src/test/java/com/smoa/modules/orders/domain/OrderServiceTest.kt @@ -0,0 +1,50 @@ +package com.smoa.modules.orders.domain + +import com.smoa.core.common.Result +import com.smoa.core.security.AuditLogger +import com.smoa.core.security.AuditEventType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.match +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Date + +class OrderServiceTest { + + private val repository = mockk(relaxed = true) + private val auditLogger = mockk(relaxed = true) + private val service = OrderService(repository, auditLogger) + + @Test + fun createOrder_insertsAndAudits() = runTest { + coEvery { repository.insertOrder(any()) } returns Unit + every { repository.getAllOrders() } returns flowOf(emptyList()) + + val r = service.createOrder( + orderType = OrderType.ADMINISTRATIVE, + title = "T", + content = "C", + issuedBy = "u1", + issuedTo = null, + effectiveDate = Date(), + expirationDate = null, + metadata = OrderMetadata(null, "J", null, emptyList(), emptyList()) + ) + + assertTrue(r is Result.Success) + coVerify(exactly = 1) { repository.insertOrder(any()) } + coVerify { + auditLogger.logEvent( + AuditEventType.POLICY_UPDATE, + userId = "u1", + module = "orders", + details = match { it != null && it.startsWith("Order created:") } + ) + } + } +} diff --git a/modules/reports/build.gradle.kts b/modules/reports/build.gradle.kts index 5b8ecb0..44c881e 100644 --- a/modules/reports/build.gradle.kts +++ b/modules/reports/build.gradle.kts @@ -49,6 +49,10 @@ dependencies { implementation(Dependencies.composeMaterial3) implementation(Dependencies.androidxCoreKtx) implementation(Dependencies.androidxLifecycleRuntimeKtx) + implementation(Dependencies.androidxLifecycleViewmodelKtx) + implementation(Dependencies.androidxLifecycleViewmodelCompose) + implementation(Dependencies.androidxLifecycleRuntimeCompose) + implementation(Dependencies.hiltNavigationCompose) implementation(Dependencies.hiltAndroid) kapt(Dependencies.hiltAndroidCompiler) diff --git a/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportGenerationScreen.kt b/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportGenerationScreen.kt index 1506e0a..c070616 100644 --- a/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportGenerationScreen.kt +++ b/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportGenerationScreen.kt @@ -1,25 +1,103 @@ package com.smoa.modules.reports.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smoa.modules.reports.domain.ReportFormat +import com.smoa.modules.reports.domain.ReportType @Composable -fun ReportGenerationScreen(modifier: Modifier = Modifier) { +fun ReportGenerationScreen( + userId: String, + modifier: Modifier = Modifier, + viewModel: ReportsViewModel = hiltViewModel() +) { + val types = ReportType.entries + val formats = ReportFormat.entries + var typeIx by rememberSaveable { mutableIntStateOf(0) } + var formatIx by rememberSaveable { mutableIntStateOf(0) } + var title by rememberSaveable { mutableStateOf("Operational summary") } + var sign by rememberSaveable { mutableStateOf(true) } + val msg by viewModel.lastMessage.collectAsStateWithLifecycle() + val len by viewModel.lastByteLength.collectAsStateWithLifecycle() + Column( modifier = modifier .fillMaxSize() - .padding(16.dp) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + Text("Reports", style = MaterialTheme.typography.headlineSmall) Text( - text = "Generate Report", - style = MaterialTheme.typography.headlineMedium + "Generate multi-format reports (JSON/XML/CSV/PDF/Excel per generator).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Type: ${types[typeIx].name}") + TextButton(onClick = { typeIx = (typeIx + 1) % types.size }) { Text("Change") } + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Format: ${formats[formatIx].name}") + TextButton(onClick = { formatIx = (formatIx + 1) % formats.size }) { Text("Change") } + } + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = sign, onCheckedChange = { sign = it }) + Text("Include content-hash signature", style = MaterialTheme.typography.bodyMedium) + } + Button( + onClick = { + viewModel.generate( + reportType = types[typeIx], + format = formats[formatIx], + title = title, + userId = userId, + sign = sign + ) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Generate") + } + msg?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + len?.let { Text("Output size: $len bytes", style = MaterialTheme.typography.labelSmall) } } } - diff --git a/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportsViewModel.kt b/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportsViewModel.kt new file mode 100644 index 0000000..8a2ca4d --- /dev/null +++ b/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportsViewModel.kt @@ -0,0 +1,56 @@ +package com.smoa.modules.reports.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smoa.modules.reports.domain.ReportFormat +import com.smoa.modules.reports.domain.ReportService +import com.smoa.modules.reports.domain.ReportType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ReportsViewModel @Inject constructor( + private val reportService: ReportService +) : ViewModel() { + + private val _lastMessage = MutableStateFlow(null) + val lastMessage: StateFlow = _lastMessage.asStateFlow() + + private val _lastByteLength = MutableStateFlow(null) + val lastByteLength: StateFlow = _lastByteLength.asStateFlow() + + fun generate( + reportType: ReportType, + format: ReportFormat, + title: String, + userId: String, + sign: Boolean + ) { + viewModelScope.launch { + reportService.signReports = sign + val body = "SMOA report: $title\nType=${reportType.name}\nFormat=${format.name}\n" + .toByteArray(Charsets.UTF_8) + reportService.generateReport( + reportType = reportType, + format = format, + title = title, + content = body, + generatedBy = userId, + template = null + ).fold( + onSuccess = { rep -> + _lastByteLength.value = rep.content.size + _lastMessage.value = "Generated ${rep.reportId} (${rep.content.size} bytes)" + }, + onFailure = { e -> + _lastMessage.value = e.message ?: "Failed" + _lastByteLength.value = null + } + ) + } + } +}