Repository navigation
feat(accounts): export and import account service overrides - #1486
Conversation
Issue LeanerCloud#9 needs a file format for account_service_overrides that can move between tenants, so each row carries the account's provider and external ID next to its UUID. The CSV form uses the export's column names, semicolon-joined lists and empty cells for unset fields, and prefixes formula-like account names so a spreadsheet does not run them. Refs LeanerCloud#9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Overrides could only be read one account at a time, so there was no way to back them up, diff them or copy them to another environment. The export reads every override on the accounts the caller's scope allows (view:accounts), sorted so two exports diff cleanly. The account index resolves import rows by account ID, else by provider and external ID, and hides out-of-scope accounts. Refs LeanerCloud#9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…report The import applies an export file as desired state: each row replaces its override (unset fields clear), and overrides not in the file stay. Rows are validated with the service-config rules and the PUT endpoint's commitment-option check. Strict mode (the default) writes nothing if any row is rejected; best_effort writes the valid rows. dry_run reports insert/update/unchanged/rejected without writing. Writes upsert, so a re-run after a failure is safe. Requires update:accounts. Refs LeanerCloud#9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the two endpoints and their schemas to openapi.yaml (the route coverage test requires every route to be documented), plus tests for scoped, ordered JSON export, the CSV round trip with formula neutralization, the format check, and route precedence over the generic /api/accounts/ prefix routes. Refs LeanerCloud#9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers export then re-import as unchanged (JSON and CSV), matching another tenant's rows by external ID, desired-state updates, strict refusal with the report and no writes, best-effort partial apply with a reason per rejected row, dry run, malformed requests, NaN coverage from CSV, and the update:accounts gate. Refs LeanerCloud#9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gives admins buttons for the new export (JSON or CSV) and an import flow: pick a JSON or CSV export, choose all-or-nothing or skip rejected rows, preview with a dry run, then import after a confirmation. Each row's outcome shows as a badge with the reason for any rejection. Refs LeanerCloud#9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers file parsing (CSV passthrough, export document or bare array, bad JSON), the summary wording, enabling the buttons on a valid file, dry-run requests in the chosen mode, rendering a strict refusal's report from the error details, the confirmation before import, and both downloads. Refs LeanerCloud#9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cross-tenant fixture put an all-digit UUID and a 12-digit account number on one line next to AccountID, which the git-secrets account-ID detector flags in the full-tree scan. Use a UUID without a 12-digit run and put the fields on separate lines. Refs LeanerCloud#9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 20 billable files and costs up to $5.00. Or wait 59 minutes for your next included review. View limit details
Comment |
|
Pushed 48bdb4e: 8 commits adding override export/import (backend, OpenAPI, tests and the Admin -> Accounts panel), as described in the PR body. |
|
Merged Local checks on the merged tree: build, vet (default, integration, e2e), golangci-lint with and without the integration tag, gocyclo, |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: REQUEST_CHANGES Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rE7. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
The export only prefixed formula-like account names, but ramp_schedule, payment, the external ID and the six include/exclude lists are stored user input too, so a stored "=HYPERLINK(...)" ran as a formula when an admin opened the export in a spreadsheet (rE7 review on LeanerCloud#1486). Every text cell now gets the OWASP quote prefix when it starts with = + - @ tab or CR, and also when it starts with a quote, so import can strip exactly one prefix and restore the stored value. A quote before any other character is not an escape and is kept. Numbers, booleans and timestamps are formatted here and stay as they are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A strict import could still write part of a file: nothing checked ramp_schedule against its VARCHAR(32) column, so a long value late in the file passed planning, the rows before it were written, and the upsert failure answered 500 with no report (rE7 review on LeanerCloud#1486). Planning now rejects, with a per-row reason, a ramp_schedule over 32 characters, coverage with more than 2 decimals (DECIMAL(5,2) would round it and the row would never read back as unchanged), and control characters in ramp_schedule or any list value (Postgres refuses NUL in TEXT). The writes then run in one WithTx transaction through the new SaveAccountServiceOverrideTx, so a store failure rolls the whole import back instead of leaving the rows before it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Fixes for rE7's REQUEST_CHANGES on Blocking 1, CSV injection in cells other than the account name (
Blocking 2, strict import could write part of a file (
Fail-before / pass-after
Non-blocking
Checks on
|
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rE9. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 1 approving independent review(s) on |
Refs #9. This PR does the export and import; the per-row audit-log entry that #9 also asks for is tracked in a separate follow-up issue, so #9 stays open.
Root cause
account_service_overridescould only be read and written one account and one service at a time (GET/PUT/DELETE /api/accounts/{id}/service-overrides/...). So there was no way to back up the override state, diff it over time, or copy it into another environment or tenant.Fix
Backend (
internal/api):GET /api/accounts/service-overrides/export(view:accounts) returns every override on the accounts the caller's scope allows. The default is JSON ({version, exported_at, overrides});?format=csvreturns CSV. Rows are sorted by provider, external ID and service, so two exports diff cleanly. Each row carriesaccount_id,account_name,account_external_id,provider,service, every override field,created_atandupdated_at. In CSV, list cells are joined with;and an empty cell means unset. Every text cell (account name and external ID, provider, service, payment,ramp_scheduleand the six include/exclude lists) that starts with= + - @, tab, CR or'gets a'prefix to block CSV injection. Import strips exactly that prefix again, so an exported value round-trips to the stored one; a'before any other character is not an escape and is kept. Numbers, booleans and timestamps are formatted by the server and are written as is.POST /api/accounts/service-overrides/import(update:accounts, AuthAdmin) takes{mode, dry_run, overrides | csv}, with 1 to 5000 rows.account_idfirst, elseprovider+account_external_id, so a file exported from another tenant imports. An account outside the caller's scope reads as unknown. A provider or external-ID mismatch, an ambiguous external ID, or a duplicate row is rejected.config.ServiceConfig.Validate, which covers provider, term 1/3, the provider-specific payment token and coverage 0..100 (NaN rejected). It also checks the service-name format, runs the PUT endpoint's commitment-option check, and checks the column bounds:ramp_scheduleat most 32 characters (VARCHAR(32)), coverage at most 2 decimal places (DECIMAL(5,2)), and no control characters inramp_scheduleor any list value (Postgres refuses NUL inTEXT). A row outside a bound is rejected with a reason, never sent to the database.strict(the default) writes nothing if any row is rejected, and answers 400 with the report indetails.report.best_effortwrites the valid rows.dry_runonly reports. The report gives each row's action (insert / update / unchanged / rejected) and the reason for any rejection.WithTx+SaveAccountServiceOverrideTx), so a store failure rolls the whole import back and nothing is written. Writes are upserts, so re-running an import is safe.openapi.yaml.Frontend: a "Service Override Backup" panel under Admin -> Accounts. It has Export JSON and Export CSV buttons. To import, you pick a
.jsonor.csvexport, choose "All rows or none" or "Skip rejected rows", and Preview (a dry run). Import then asks for confirmation. The per-row report shows as status badges with reasons.Not done here, tracked separately: issue #9 asks for a
config_audit_eventsentry per imported row "the same as a UI edit". The override PUT/DELETE endpoints write no audit events today either, so import behaves like the existing PUT on this point. Auditing overrides (import, PUT and DELETE) is tracked in a separate follow-up issue, which is why this PR refs #9 instead of closing it.Verification
handler_override_transfer_test.goandhandler_override_import_test.go, run against an in-memory store with the real auth decision code.ramp_schedule: 400 with the per-row report, nothing written. A store failure on row 40 (strict and best_effort): nothing written. Coverage precision and control characters are rejected with reasons./api/accounts/prefix routes, and the existing OpenAPI route-coverage test.go test -race -short ./internal/apiand-tags integration ./internal/apipass. build, vet (plain, integration, e2e): pass. golangci-lint v2.10.1, with and without integration: 0 issues. gocyclo: no new function over 9.override-transfer.test.ts. lint (0 errors, warnings unchanged at 141), typecheck, build, full jest (4515 passed) and Playwright viae2e-local.sh(267 passed) all pass.scripts/test-git-secrets-allowlist.sh: 183 PASS.Postgres:
TestImportOverrides_StrictIsAllOrNothingPostgres(integration) runs the import against a migrated database: the 33-character row is a 400 with no rows written, a trigger that refuses row 40 rolls back rows 1-39, and the clean import then writes all 40. Frontend follow-up: cristim owns the UI, so the panel's layout and wording are open to his preference.🤖 Generated with Claude Code