SA
Sanchalan Docs
Process flows

Process flows

The four cross-cutting flows that capture every interaction with the system. All sequence diagrams reflect the actual code paths, not idealised ones — middleware, validation, and audit-logging steps are explicit.

1. SSO PKCE login (mobile)

The mobile app never holds a client_secret. It generates a per-login code_verifier and sends only the code_challenge (sha256-base64url) to the IDP. The backend exchanges the auth code with sb-iam server-side and mints a Sanctum personal-access-token.

sequenceDiagram participant App as Mobile app participant Be as attendance backend participant Browser as System browser participant IAM as sb-iam (Parichay) participant App2 as Mobile app (deep-link) App->>App: generate code_verifier + code_challenge + state App->>Be: POST /auth/sso/mobile/redirect
{code_challenge, state, redirect_uri} Be->>Be: validate redirect_uri allowlist Be-->>App: { authorization_url } App->>Browser: launchUrl(authorization_url) Browser->>IAM: GET /oauth/authorize?... IAM->>Browser: login form Browser->>IAM: credentials IAM-->>Browser: 302 sanchalan-attendance://auth/callback?code=...&state=... Browser->>App2: deep-link App2->>App2: verify state matches App2->>Be: POST /auth/sso/mobile/exchange
{code, code_verifier, redirect_uri} Be->>IAM: POST /oauth/token (server-to-server) IAM-->>Be: { access_token } Be->>IAM: GET /api/auth/user (Bearer) IAM-->>Be: { email, name, role, parichay_id } Be->>Be: mapUser → User row + auto-link employee_id by email Be->>Be: createToken('mobile', [scopes]) — Sanctum PAT Be-->>App2: { access_token, user } App2->>App2: TokenStore.save (flutter_secure_storage)

2. Punch decision matrix

Every punch is recorded for audit, even rejected ones — the verdict column captures why. The decision goes through 5 gates in order; the first failing gate sets the verdict.

flowchart TD Start([POST /v1/punch]) --> AuthCheck{auth:sanctum
+ employee_id
on token?} AuthCheck -- no --> R401([401]) AuthCheck -- yes --> Throttle[throttle:30/min] Throttle --> Validate[validate device_uuid,
punch_type, punched_at,
lat/lng, nonce, signature] Validate -- 422 --> R422a([422 validation]) Validate --> DeviceLookup{find active device
for this employee?} DeviceLookup -- no --> R404([404 — register first]) DeviceLookup -- yes --> SigCheck["1. PunchSignatureVerifier.verify(
device.public_key_pem,
nonce + device_uuid + punched_at,
raw r||s ECDSA P-256)"] SigCheck -- bad --> Reject1[verdict = rejected_signature] SigCheck -- ok --> FenceCheck["2. GeoFenceCheck.check(
lat, lng, ssid, employee)
— point-in-polygon + SSID allowlist"] FenceCheck -- outside --> Reject2[verdict = rejected_geofence] FenceCheck -- inside --> SpoofCheck["3. AntiSpoofPolicy.evaluate(
mock_location, rooted, emulator)
— policy = strict | permissive"] SpoofCheck -- mock|root|emu --> Reject3[verdict = rejected_spoof] SpoofCheck -- clean --> ReplayCheck["4. INSERT into attendance_punches"] ReplayCheck -- "unique(employee_id, nonce)
WHERE verdict=accepted violated" --> Reject4[verdict = duplicate] ReplayCheck -- ok --> Accept[verdict = accepted] Reject1 --> Persist[persist punch row] Reject2 --> Persist Reject3 --> Persist Accept --> Persist Reject4 --> Audit Persist --> Audit["AuditClient.log
attendance.punch.{verdict}"] Audit --> Resp{verdict?} Resp -- accepted --> R201([201 + punch_id]) Resp -- otherwise --> R422b([422 + reason])

3. Leave application + approval

The CCS Leave Engine validates each application against per-type rules (CL/EL/HPL/COMMUTED/CCL/RH) before persisting. Approval deducts balance atomically.

sequenceDiagram participant U as Employee participant Be as backend participant Eng as CcsLeaveEngine participant DB as Postgres participant A as AuditClient participant Off as Reporting officer U->>Be: POST /v1/leaves
{leave_type, start_date, end_date, is_half_day} Be->>Be: validate (Rule::in for type) Be->>Eng: validateApplication(emp, type, start, end, halfDay) Eng->>Eng: computeXxx(start, end) — type-specific calc Eng->>DB: SELECT balance for (emp, type, year) alt insufficient balance Eng-->>Be: LeaveVerdict(ok=false, reason) Be-->>U: 422 leave_rejected else COMMUTED — also check 2× HPL Eng-->>Be: LeaveVerdict(ok=false, 'insufficient HPL') Be-->>U: 422 else RH — also check 2/year cap Eng-->>Be: LeaveVerdict(ok=false, 'RH cap exceeded') Be-->>U: 422 else ok Eng-->>Be: LeaveVerdict(ok=true, days_consumed) Be->>DB: INSERT attendance_leave_applications status=pending Be->>A: attendance.leave.applied Be-->>U: 201 + application end Note over Off,DB: ... time passes ... Off->>Be: POST /admin/leaves/{id}/decide
{decision: approve|reject, note} Be->>Be: rbac:admin-attendance ✓ Be->>DB: BEGIN Be->>DB: UPDATE status, decision_history += entry alt approved Be->>Eng: deductBalance(application) Eng->>DB: UPDATE balance: used_days += days_consumed alt COMMUTED Eng->>DB: UPDATE HPL balance: used_days += days × 2 end end Be->>A: attendance.leave.{approved|rejected} Be->>DB: COMMIT Be-->>Off: 302 redirect-back + flash

4. Audit chain

Every state-changing action funnels through AuditClient. Hash-chain integrity is anchored daily by audit-chain-anchor-sign.sh.

flowchart LR Action["controller action
(punch / device / leave / tour)"] -->|"AuditClient.log()"| Stream[("Redis stream
sds:audit:events")] Stream -->|"XREADGROUP attendance"| Audit["audit container
(audit:consume)"] Audit -->|"writeToPostgres"| Table[("sds_audit.audit_events
+ prev_hash, hash")] Audit -->|"XACK + XDEL"| Stream Cron["sds-ops cron daily"] -->|"audit-chain-anchor-sign.sh"| Table Table -->|"chain_anchor_signature"| External["external timestamp
(future: TSA / blockchain)"]

What lands in each audit event

{
  "id":            int,
  "app":           "attendance",
  "user_id":       int (the actor),
  "user_email":    "...",
  "action":        "attendance.punch.accepted",
  "entity_type":   "attendance_punch",
  "entity_id":     "12345",
  "payload":       {...domain-specific JSON...},
  "ip_address":    "...",
  "user_agent":    "...",
  "request_id":    uuid,
  "created_at":    timestamp,
  "prev_hash":     sha256 of previous row,
  "hash":          sha256 over canonicalised current row,
  "payload_hash":  sha256 of payload only
}

5. Tour / out-of-office workflow

Same shape as leave, but no balance deduction (tours are time on duty, not absence). Approval merely flips status and audit-logs.

sequenceDiagram participant U as Employee participant Be as backend participant DB as Postgres participant A as AuditClient participant Off as Approver U->>Be: POST /v1/tours
{purpose, location, from/to_date, reason} Be->>DB: INSERT attendance_tours status=pending Be->>A: attendance.tour.applied Be-->>U: 201 Off->>Be: POST /admin/tours/{id}/decide
{decision, note} Be->>DB: UPDATE status + decision_history Be->>A: attendance.tour.{approved|rejected} Be-->>Off: 302 + flash

6. Offline punch replay (mobile)

If the device has no connectivity at punch time, the signed payload is enqueued in Hive (local) and replayed when connectivity returns. The signature was computed locally so it remains valid at any later time, subject to the server's clock-skew tolerance.

flowchart TD T[user taps Punch IN] --> S[sign locally] S --> Try[POST /v1/punch] Try -- network error --> Q[OfflineQueue.enqueue] Try -- 2xx/4xx --> Done[show verdict] Q --> Wait((wait for connectivity)) Wait -- connectivity event --> Drain[drainOffline loop] Drain --> Try2[POST /v1/punch in submission order] Try2 -- success --> Drop[OfflineQueue.drop] Drop --> Drain Try2 -- network --> Stop[stop, retry next event] Q --> Evict48["evictStale (>48h) on every open"]