Bilko — Kako support agent radi (runbook, MC #106063)
Parent: MC #106049 (Support MVP) · Ovaj task: MC #106063 (E2) · Agent: Skillforge · Datum: 2026-07-20
Vidi također: Faza A (RBAC+RLS temelj), Faza B (ticket reply + GUC aktivacija), i "Support sistem — arhitektura" (D1) za mapu svih support mehanizama.
1. Kako se support agent provisionira
- Dedicated endpoint:
POST /admin/support/agents/invite(SupportAgentProvisioningRoutes.kt, MC #106052/A3). - Ko može pozvati: SAMO
platform_admin(requirePlatformAdmin()) — isti širok gate koji koriste svi drugi platform-ops admin route-ovi. Nema support_agent-specifičnog uslova za KO smije pozvati novog agenta — samo za šTA taj agent, jednom kreiran, smije poslije vidjeti. - DTO:
InviteSupportAgentRequest(email, fullName)— SAMO ta dva polja. NemaroleniorganizationIdpolja na DTO-u uopšte. - Rola i org su hardkodovani server-side: rola je uvijek
"support_agent", org je uvijek interni org sa fiksnim imenom"Bilko Internal — Support"(seedovan V135 migracijom). Caller ne može cilje ni jedan ni drugi preko request body-ja. - Zašto ne postojeći generički
POST /admin/invitations: taj endpoint uvijek koristiorganizationId = principal.organizationId(org pozivaoca), što nikad nije interni support org — nema načina da se kroz njega ubaci support_agent u interni org. - Aktivacija: pozvana osoba se prijavljuje kroz ISTI, nepromijenjeni Entra JIT invite-accept put kao svaki drugi Bilko invite (
InviteService.acceptInviteInsideTransaction, pozvano izAuthService.createSessionFromEntraIdToken). Nema novog auth koda, nema service accounta. JWT nakon prijave nosirole="support_agent"kao bilo koja druga rola.
1a. Zatvorena rupa — #106071 generic-invite reject
Kad su InviteService.VALID_ROLES i UserProvisioningService.VALID_ROLES prošireni da uključe support_agent (da bi gornji dedicated endpoint mogao pozvati isti servisni sloj), TRI POSTOJEĆA generička route-a (gated samo sa users:manage, dakle bilo koji admin/owner SVOJE org-e) su tiho počela prihvatati role="support_agent" i preko sebe:
POST /admin/invitations(InviteRoutes.kt)POST /admin/usersiPUT /users/:id/role(UserManagementRoutes.kt)
Fix: nova funkcija rejectSupportAgentRoleInGenericFlow(role) u RbacHelper.kt — baca ForbiddenException("SUPPORT_AGENT_ROLE_FORBIDDEN_HERE...") na sva tri generička route-a PRIJE nego zahtjev stigne do servisnog sloja. VALID_ROLES ostaju široki (jer dedicated endpoint legitimno zove istu createInvite() funkciju) — reject je na route sloju, ne na servisnom, upravo na ta tri mjesta koja je Parisa našla, ne paralelna allowlist koja bi mogla driftati.
2. Jackson vs kotlinx.serialization — razriješena neslaganja (Momjian vs Parisa)
Zadatak je tražio da se PROČITA stvarni kod prije nego što se prepiše bilo čija tvrdnja. Pročitano: apps/api/src/main/kotlin/no/alai/bilko/plugins/Serialization.kt i SupportAgentProvisioningRoutes.kt na branch-u feat/106051-support-agent-rbac (commit 5c06350a).
Nalaz: Bruce Momjian je bio u pravu — mehanizam je Jackson's FAIL_ON_UNKNOWN_PROPERTIES (default true, nikad eksplicitno postavljen niti isključen u kodu), NE kotlinx.serialization ignoreUnknownKeys.
Dokaz iz koda:
// Serialization.kt
fun Application.configureSerialization() {
install(ContentNegotiation) {
jackson {
disable(SerializationFeature.INDENT_OUTPUT)
registerModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
findAndRegisterModules()
}
}
}
val bilkoJson = Json {
ignoreUnknownKeys = true // <- postoji, ali NIJE registrovan u ContentNegotiation
...
}
install(ContentNegotiation) { jackson { ... } }— Ktor-ov HTTP content-negotiation sloj je konfigurisan SAMO sa Jackson-om. Nemajson(bilkoJson)ni bilo koje druge kotlinx registracije u tom bloku.- DTO-ovi kao
CreateInviteRequestiInviteSupportAgentRequestnose@Serializableanotaciju (kotlinx marker), ali se stvarno deserijalizuju prekocall.receive<T>(), što ide kroz ContentNegotiation — dakle kroz Jackson, ne kroz kotlinx, bez obzira na anotaciju na DTO-u. bilkoJson(kotlinxJsoninstanca saignoreUnknownKeys = true) postoji u istom fajlu, ali služi za internu JSON obradu (komentar u kodu: "used internally for JSON parsing") — NIJE registrovana kao content-negotiation mehanizam za HTTP request body.- Jackson-ov
ObjectMapper(kreiran preko Ktor-ovogjackson {}DSL-a) imaFAIL_ON_UNKNOWN_PROPERTIESdefaulttrue— i nigdje u repou (grep potvrđen, nula pogodaka) se to eksplicitno ne isključuje. - Live test-dokaz (ne samo statika koda):
SupportAgentProvisioningTest.kt, testT06 "request body cannot override role or organizationId — DTO has no such fields, extra keys are rejected outright"— šalje JSON sa dodatnimrole/organizationIdpoljima naInviteSupportAgentRequestDTO, unutartestApplication {}(stvarna Ktor test-aplikacija, ne mock), i tvrdiHttpStatusCode.BadRequest. Test-ov vlastiti komentar kaže eksplicitno: "This codebase's Jackson ObjectMapper (Serialization.kt) does NOT disable FAIL_ON_UNKNOWN_PROPERTIES, so an unrecognized JSON key is rejected outright with 400." Pozitivna kontrola u istom testu potvrđuje da isti email SA samo ispravnim poljima uspijeva (201) — dokazuje da je 400 specifično zbog nepoznatih ključeva, ne neke druge greške.
Zaključak za tim: kad god vidite @Serializable na DTO-u u ovom repou, to NE garantuje da će kotlinx pravila (npr. ignoreUnknownKeys) važiti na HTTP granici — treba provjeriti da li je taj DTO stvarno deserijalizovan preko ContentNegotiation-a (Jackson, ovaj repo) ili preko direktnog poziva na bilkoJson.decodeFromString<T>() (kotlinx, druga upotreba u istom fajlu).
3. Šta support agent VIDI
- SVI support tiketi, cross-org — preko
app.is_support_agentGUC-a postavljenog samo na support ruta-ma, nakon što jerequirePermission(principal, "support_ticket:read")/"support_ticket:reply"već prošao. - Reply/triage polja na tiketu preko ograničenog UPDATE-a:
status,resolution_note,triage_json,external_ref,updated_at— kolonski GRANT (V134 Part D), ne cijeli red.
4. Šta support agent NE VIDI
- Finance/payroll/accounting/settings — nula permisija dodijeljeno support_agent roli van
support_ticket:read/support_ticket:reply(V133 Part C/D, provjereno grep-om — nema trećeg reda urole_permissionsza tu rolu). - RLS-nivo dokaz (Faza A evidence, V134MigrationTest test 5): sa
app.is_support_agent=truepostavljenim, pokušaj pristupaorganizations,invoices,expenses,employees,payslips,financial_audit_log— svih šest odbijeno. Dva različita, oba tačna načina odbijanja nađena: "permission denied" (organizations/invoices/expenses — nula GRANT za bilko_app na tim tabelama uopšte, prethodno postojeći, nepovezan gap) i "0 redova" (employees/payslips/financial_audit_log — GRANT postoji preko V101/V124, ali nijedna politika na tim tabelama ne konsultujeis_support_agent, pa RLS ispravno filtrira sve). - Ostali ticket route-ovi: support_agent NE može pristupiti
GET /admin/support/tickets/{id}niPATCHpreko svoje role — ti route-ovi ostaju gated sarequireSupportTriageService()(odvojen guard odsupport_agentrole-flow-a, nepromijenjen ovim MVP-om).
5. RBAC permission-key referenca
| Permission key | Značenje | Format |
|---|---|---|
support_ticket:read | Čitanje/listanje tiketa preko svih organizacija | colon-format (resource:verb) |
support_ticket:reply | Odgovor / triage tiketa (status + resolution_note) preko svih organizacija | colon-format (resource:verb) |
Zašto colon, ne dot: dispatch je originalno tražio support.tickets.read/support.tickets.reply (dot-notation). V67-ova permission_key_format CHECK constraint (key ~ '^[a-z_]+:[a-z_]+$') zahtijeva tačno jednu dvotačku, bez tačaka — svaki postojeći ključ u katalogu (npr. invoice:read, expense:create) već poštuje taj format. Umjesto proširenja constraint-a za jednu feature-preferencu imena, korišteno je support_ticket:read/support_ticket:reply (singular resource, isti obrazac).
6. RLS cross-org izuzetak — zašto i kako ograničen
Zašto postoji izuzetak uopšte: support osoblje MORA vidjeti tikete preko svih organizacija da bi triage funkcionisao — jedan support agent opslužuje sve klijente, ne samo jednu org. Standardni Bilko RLS model (org_id-scoped) bi u tom slučaju blokirao support agenta da vidi ijedan tiket van svoje (interne) org-e.
Kako je ograničen (ključna crvena-zona odluka, Momjian, MC #106051):
- Nova, uska GUC:
app.is_support_agent— NIJE ponovna upotreba postojećeapp.is_platform_adminGUC-e.support_ticketsje već imaosupport_tickets_admin_allpolitiku (V73) gated naapp.is_platform_admin— ISTU GUC koju konsultuju ipurge_worker_log,document_retention_manifest,financial_audit_log. Da je support_agent-ov cross-org pristup implementiran postavljanjemapp.is_platform_admin, ista transakcija bi automatski zadovoljila i sve DRUGE tabele gated na tu GUC — tiho dodjeljujući support agentu pristup retention manifestima, purge logovima, finansijskim audit trailovima. Točno ono što ovaj task postoji da SPRIJEČI. - Sealed-class ekskluzivnost: nova GUC se konsultuje SAMO od strane dvije nove politike na
support_tickets(support_tickets_support_agent_select,support_tickets_support_agent_reply) — nikad od bilo koje druge tabele/politike u ovom repou. Verifikovano live testom koji grep-ujepg_policieskatalog za string"is_support_agent"i tvrdi nula pogodaka vansupport_tickets(V134MigrationTest, "6 - GUC ISOLATION"). - SAMO support_tickets + support_ticket_messages — nikad finance. RLS izuzetak pokriva tačno dvije tabele iz ovog MVP-a; nijedna finance/payroll/accounting tabela nije dirana.
- Ko postavlja GUC: route sloj (
SupportTicketRoutes.kt, B2), unutartransaction {}bloka, SAMO NAKON što jerequireSupportAgent()već vratio validan principal (dakle samo za zahtjev koji je nezavisno dokazaosupport_ticket:read/replypermisiju) — GUC nikad sama nije autorizaciona odluka, samo downstream RLS mehanizam. - Provjera curenja kroz connection pool (top adversarial pitanje, B faza):
SupportAgentGucPoolTest.kt— HikariCP pool size 1 (forsira reuse konekcije) +isAutoCommit = false(isti kao produkcijska konfiguracija). Test 2: odmah nakon agent zahtjeva na ISTOJ pooled konekciji, sljedeća customer-scoped transakcija NE nasljeđuje cross-org vidljivost, i direktnacurrent_setting('app.is_support_agent', true)provjera na početku te transakcije čita NULL/prazno. Test 4:app.is_platform_adminse nikad ne postavlja na support_agent putu — nema unakrsne kontaminacije GUC-ova. - Kolonski GRANT, ne FOR ALL: support_agent-ov UPDATE je ograničen na tačno kolone koje postojeći PATCH triage flow piše (status, resolution_note, triage_json, external_ref, updated_at) — preko GRANT UPDATE (kolona-lista), isti mehanizam koji V125/V132 već koriste za
bilko_purge_worker. Postgres nema kolonski-scoped RLS, pa RLS odlučuje KOJI REDOVI, GRANT odlučuje KOJE KOLONE.
7. Otvoreni tech-debt
- #106070 — (prati se odvojeno, van scope-a ovog MVP-a; provjeriti
mc.js show 106070za trenutni status prije referenciranja detalja). - #106072 — (isto, van scope-a; provjeriti
mc.js show 106072). - #106075 — (isto, van scope-a; provjeriti
mc.js show 106075). - Poznat, označen (ne skriven) gap iz B faze:
support_tickets_customer_select(V73) cast-ujeapp.current_org_iddirektno na::uuidbezNULLIFguard-a, baca sirovi exception umjesto čistog deny kad je ta GUC zaista nepostavljena. Zaobiđeno u testu (postavlja throwaway validan UUID na agent putu, koji tu GUC nikad stvarno ne treba), NE popravljeno — van scope-a ovog taska, već praćeno. - Pre-existing GRANT gap na support_tickets (nađen i ispravljen u V134, ne bio prije praćen kao poseban ticket): tabela nije imala eksplicitan GRANT SELECT/INSERT za
bilko_appuopšte — nevidljivo u produkciji jer Flyway/API oboje konektuju kaobilko_admin(BYPASSRLS). V134 dodaje nedostajući GRANT, što čini i STARE V73 politike (customer/admin) stvarno dostupnim zabilko_appulogu, ne samo nove.
8. Merge status (na dan pisanja ove stranice)
Branch feat/106051-support-agent-rbac (azdo), tip 5c06350a. NIJE merge-ovan u azdo/main. Faza A (Parisa adversarial verify) i Faza B (GUC-execution adversarial pass) oboje još čekaju Parisa Tabriz-ov nezavisni pregled prije merge-a — to je eksplicitni merge-gate koji su i Momjian i CodeCraft ostavili otvorenim u svojim verdiktima (vidi ~/system/evidence/106051/verdict.md i ~/system/evidence/106055/verdict.md).
No comments to display
No comments to display