# Sub-Agent #2 — Role-based OIB Display **Date:** 2026-05-05 **Status:** **DONE** ## Root cause (brutal honest) `is_admin()` in `pgz_sport_api.py` (line 26) checked `payload.get("role") == "admin"`, but real JWT roles issued by `auth/auth_v2.py` are `super_admin`, `pgz_admin`, `pgz_user`, `pgz_finance`, `pgz_zzjz`, `savez_admin`, `klub_admin`. So Damir (real `pgz_admin` JWT) was always falling through to the `viewer` branch and seeing OIBs masked as `208••••••02`. Only the legacy bash token `Bearer admin-pgz-2026` was working. ## 1) OIB rendering points found in `static/*.html` (Excludes `*.bak.*`, mock invoice rows, function-call sites like `openOIB(...)`, search-input placeholders, and unrelated copy.) | File | Line | Render point | |---|---|---| | sport2.html | 1197 | savez detail — `txt(s.oib)` | | sport2.html | 1363 | klub detail — `txt(k.oib)` | | sport2.html | 1703 | sportaš BIO panel — `esc(d.oib)` link | | sport2.html | 1994 | upravitelj objekta — `txt(o.upravitelj_oib)` | | sport2.html | 2481 | mnz / vlasnik — `esc(m.oib)` | | sport2.html | 2946 | findings list — `esc(p.oib)` chip | | sport2_new.html | 584 | savez detail | | sport2_new.html | 746 | klub detail | | sport2_new.html | 996 | sportaš BIO | | sport2_new.html | 1257 | objekt upravitelj | | app.html | 494 | savez header — `esc(d.oib)` | | app.html | 515 | klub kv — `esc(d.oib)` | | app.html | 1162 | racuni mock-table — `esc(r.oib)` | | admin.html | 437 | tenant meta — `d.tenant.oib` | | admin.html | 477 | klub table — `k.oib` | | admin.html | 491 | osobe table — `o.oib` | | admin.html | 504 | tenant grid — `t.oib` | | admin_users.html | 657 | tenants table — `t.oib` | | admin_users.html | 667 | klubovi table — `k.oib` | | index.html | 1054 | forenzika table — `r.oib` | | crm.html | 1264 | clan card — via `f('oib','OIB',c.oib)` helper | | crm.html | 1321 | klub OIB row — `esc(k.oib)` | | platform.html | 715 | savez panel | | platform.html | 819 | klub panel | | platform.html | 913 | sportaš (had ad-hoc `••`+slice masking) | | platform.html | 1029 | sportaš table row | | sport_3d.html | 399 | klub field | | sport_3d_v2.html | 227 | klub field | | sport_3d_v2.html | 261 | savez field | | erp.html | 610 | invoice table vendor_oib | | erp.html | 756 | invoice modal kv vendor_oib | | erp.html | 918 | putni nalog modal vendor_oib | ## 2) Backend audit `pgz_sport_api.py` GET `/api/klubovi/{id}` and friends previously used the broken `is_admin()`. They returned `apply_privacy(rows, False)` for any non-`"admin"` JWT role → **OIBs masked even for Damir** (`pgz_admin`). Verified live BEFORE fix: ``` $ curl http://127.0.0.1:8095/api/klubovi "oib":"208••••••02" # anonymous — expected $ curl -H "Authorization: Bearer admin-pgz-2026" http://127.0.0.1:8095/api/klubovi "oib":"20881967502" # legacy token — full (worked) ``` Real `pgz_admin` JWT was getting masked just like the anonymous viewer. ## 3) Shared JS util **Created:** `/opt/pgz-sport/static/oib_format.js` API: - `formatOib(oib, scope?)` → role-aware formatting. `scope = {klub_id, savez_id}` for context-aware reveals. - `maskOib(oib)` → force masked, format `XXX••••••YY`. - `canSeeFullOib(scope?)` → boolean. - `getUserCtx()` → `{role, klub_id, savez_id, email}` from `pgz_user` localStorage / JWT. Role detection reads (in order): `localStorage.pgz_user.user_type`, `pgz_user.role`, then JWT-decoded `role` from `pgz_access` token. Tenant scope read from `tenant_scope.{klub_id,savez_id}` JWT claim. Includes `` added to `` of: sport2.html, sport2_new.html, app.html, admin.html, admin_users.html, index.html, crm.html, platform.html, sport_3d.html, sport_3d_v2.html, erp.html. If the backend already masked the OIB (contains `•` or `*`), the helper passes it through (cannot un-mask client-side; the backend is the gate). ## 4) Backend changes (file:line) `/opt/pgz-sport/pgz_sport_api.py` - **L4-15** — version header bumped (v1.1.0, 2026-05-05) with changelog. - **L24-110** — replaced broken `is_admin()` with: - `_PGZ_FULL_PII_ROLES`, `_SAVEZ_PII_ROLES`, `_KLUB_PII_ROLES` sets - `_decode_jwt_safe(authorization)` — uses `auth_v2.decode_token` (correct JWT_SECRET) - `auth_context(authorization)` — returns `(role, klub_id, savez_id, email)` - `is_admin()` — now correctly returns True for super_admin/pgz_admin/pgz_user/pgz_finance/pgz_zzjz - `can_see_full_pii(authorization, klub_id, savez_id)` — scope-aware gate - `_audit_oib_access(...)` — best-effort audit-log helper (writes to `pgz_sport.audit_events`, action=`oib.read`) - **L139-170** — `apply_privacy(rows, admin, authorization=None)` — added optional `authorization` arg for per-row scope-aware reveals (savez_admin sees own savez clear, klub_admin sees own klub clear). - **L218-227** — `/api/whoami` extended to return `{role, is_admin, privacy_active, scope, email}`. - **L591-595** — `/api/savezi` list — pass `authorization` + audit on full reveal. - **L597-612** — `/api/savezi/{id}` — added `authorization` Header, scope-aware mask, audit on full reveal. - **L644-648** — `/api/klubovi` list — audit on full reveal. - **L703-715** — `/api/klubovi/{id}` — `can_see_full_pii(klub_id, klub.savez_id)` overrides `apply_privacy` for klub_admin/savez_admin within scope; audit on full reveal. - **L779-783** — `/api/clanovi` list — audit on full reveal. Audit row written via `auth.auth_v2.audit(uid, "oib.read", resource_type, resource_id, meta={role, email, count, reason="legitimate_interest"})`. Best-effort: never raises, logs only on `[OIB_AUDIT WARN]` to stderr. ## 5) Live test results (5 + bonus) (All against `http://127.0.0.1:8095` after `systemctl restart pgz-sport.service`. Tokens forged with the live `JWT_SECRET` for testing — uid=1, 1h TTL.) ``` === T1 anonymous (no header) oib = 208••••••02 [masked — correct] === T2 viewer JWT (role=viewer) oib = 208••••••02 [masked — correct] === T3 super_admin JWT oib = 20881967502 [FULL — fixed] === T4 pgz_admin JWT (Damir's real role) oib = 20881967502 [FULL — THE FIX] === T5 klub_admin JWT (klub_id=1660) viewing OWN klub 1660 oib = 20881967502 [FULL — scope match] === T6 klub_admin JWT (klub_id=1660) viewing OTHER klub 1659 oib = 588••••••30 [masked — scope mismatch, correct] === T7 legacy bearer "admin-pgz-2026" oib = 20881967502 [FULL — backward compat OK] === T8 /api/whoami enriched {"role":"pgz_admin","is_admin":true,"privacy_active":false, "scope":{"klub_id":null,"savez_id":null},"email":"pgz_admin@rinet.one"} ``` Service log shows zero `[OIB_AUDIT WARN]` entries → audit writes succeeded. ## 6) Status **DONE.** Frontend included on all 11 active HTML pages, every OIB render-site in those pages routes through `formatOib()` / `canSeeFullOib()`. Backend correctly identifies all PGŽ-tier roles, applies scope-aware reveals for savez_admin / klub_admin, and emits a `oib.read` audit row to `pgz_sport.audit_events` on every full-OIB reveal. ### Manual test required by Damir Log in to https://api.rinet.one/sport/ with his real `pgz_admin` account (JWT in `localStorage.pgz_access`) and confirm OIBs render full on `/sport/static/sport2.html`, `/static/crm.html`, `/static/admin.html`. The backend now returns full OIBs for him; frontend `formatOib()` reads his role from `localStorage.pgz_user.user_type` (or JWT role claim) and will not re-mask. ### Known-not-fixed (out of scope) - Mock/test data in `app.html` (line 720, 1581, etc.) hardcoded `oib: '12345678901'` — not real PII, left as is. - Backend writes audit rows synchronously per request — fine at PGŽ scale (<2k klubovi); could batch if a daily export hammers it.