[CXH-1585] feat: account provisioning + enable/disable user actions via Retool REST API#36
Conversation
…H-1585)
Augment the Postgres-only connector with a second auth surface (REST) for
the user account lifecycle. Sync and existing group/page provisioning stay
100% on Postgres and are unchanged.
- config: add retool-api-base-url + retool-api-token (FieldsRequiredTogether,
optional — sync-only deployments keep working without them).
- client: add an optional bearer-auth REST client (uhttp) alongside the pgx
pool, a doRequest chokepoint, and CreateUser/GetUserByEmail/DeleteUser +
ValidateREST against /api/v2/users. user.go gains GetUserSID to resolve the
synced user:<int64> (legacy_id) to the REST sid via Postgres — no mutable
email join.
- connector: AccountCreationSchema in Metadata(), REST probe in Validate()
(only when configured), New() threads the REST config through.
- users: CreateAccount (returns the durable user:<legacy_id> from the create
response), CreateAccountCapabilityDetails, and Delete. Delete tolerates 404
and 422 ("already disabled") as success.
Notes on Retool semantics (verified against a live instance):
- The REST base is /api/v2 (not /api/v1).
- DELETE is a soft deactivation (sets enabled=false, keeps memberships,
reversible); Retool has no hard delete.
- Enable/disable as a connector Action is deferred — the connector's SDK
version has no action framework.
Docs: README, public connector.mdx (capabilities + REST-token setup +
self-hosted env vars), and new internal docs/docs-info.md updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connector PR Review: [CXH-1585] feat: account provisioning + enable/disable user actions via Retool REST APIBlocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0 Review SummaryThis commit ( Security IssuesNone found. Correctness IssuesNone found. SuggestionsNone. |
baton-retool can't be exercised with a simple mock — sync + group/page provisioning need Retool's internal Postgres, and account provisioning needs the Retool REST API. This adds a self-contained integration test that stands up a throwaway self-hosted Retool (postgres + jobs-runner + api) via Docker, seeds an admin/org, mints a REST token, and runs sync + group grant/revoke + account provisioning (create -> delete -> dup-delete) against it. - test/integration/compose.yaml — minimal 3-service Retool stack. - test/integration/setup.sh — wait for health, idempotent admin signup, mint a users:read+users:write PAT via SQL (hashedKey = sha256(token)). - test/integration/provisioning-test.sh — account create/delete/dup-delete + assertions via the REST API. - .github/workflows/integration-test.yaml — schedule + workflow_dispatch (heavy: image pull + DB migrations, so not per-PR). Account-provisioning script verified end-to-end against a live local Retool. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- client: doRequest now returns the HTTP status code instead of the raw *http.Response — fixes the bodyclose lint failures (uhttp already drains and closes the body) and stops the response escaping the chokepoint. Callers branch on the status code. - client: build the request URL with url.URL.JoinPath so a base-URL path prefix (e.g. a reverse proxy at /retool) is preserved instead of dropped. - client: convert CreateUserParams to createUserRequest directly (gosimple). - config: mark retool-api-token WithHidden(true) so the bearer token stays out of --help output and config dumps (this SDK predates WithIsSecret). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- field.NewConfiguration now takes relationship constraints via field.WithConstraints(...) - retool-api-token is declared field.WithIsSecret(true) (the WithHidden workaround predated the option) - CreateAccount takes *v2.LocalCredentialOptions — v0.13.0 rejects the old AccountManager interface at startup, not at compile time - drop the Unimplemented Create() stub: ResourceDeleter is standalone now, Delete alone no longer requires the full ResourceManager pair Verified live against a self-hosted Retool 3.334.17: full sync, account create -> deactivate -> idempotent duplicate-delete, group grant/revoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fold the docker-based integration job into ci.yaml (the standalone integration-test.yaml never ran: schedule/workflow_dispatch require the file on the default branch). Local connectors baton-mysql and baton-ldap already run their service in Docker on every PR. A concurrency group cancels superseded runs on new pushes. Also pin Go via go-version-file: go.mod everywhere (the SDK bump moves the directive to 1.25.2; main.yaml hardcoded 1.24.x). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
golangci-lint v1.64.8 (the newest v1, built with go1.24) refuses to target the go 1.25.2 directive the SDK bump requires. Migrate .golangci.yml to the v2 format (golangci-lint migrate; dropped long-invalid deadcode/varcheck entries first), move both workflows to golangci-lint-action@v9 with a pinned v2.11.1, and fix the 8 staticcheck QF1012 findings the v2 linter surfaces (sb.WriteString(fmt.Sprintf(...)) -> fmt.Fprintf(sb, ...)). Local `make lint` works again too — the v2 binary previously couldn't read the v1-format config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retool's REST API has no hard delete — verified live: DELETE
/api/v2/users/{id} returns 204 but only sets active=false. Exposing that
as an account Delete misrepresents a reversible deactivation as a
removal, so:
- drop userSyncer.Delete and the REST DeleteUser call
- add enable_user/disable_user global actions
(ACTION_TYPE_ACCOUNT_ENABLE/DISABLE) backed by PATCH /api/v2/users/{id}
{"operations":[{"op":"replace","path":"/active","value":<bool>}]},
reusing the legacy_id -> sid Postgres resolution
- CreateAccount stays unchanged
- rewrite the integration lifecycle test: create -> disable -> enable ->
duplicate-disable (idempotent), via --invoke-action
- docs: no-hard-delete note, actions, and add the missing SELECT ("sid")
grant for the dedicated baton Postgres user (sid resolution needs it)
Verified end-to-end against a self-hosted Retool 3.334.17.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- config: add WithDisplayName to the REST fields; add connector display
name, icon URL, and help URL to the configuration
- connector: title-case Metadata DisplayName ("Retool"); prefix errors
with "baton-retool:"; surface REST rate-limit annotations from Validate
- client: build the REST client via uhttp.NewClient (WithLogger +
WithTimeout) then wrap it, so HTTP request/response debug logs fire
(the raw http.Client bypassed the logging transport); thread rate-limit
annotations through doRequest and all REST callers
- users: prefix errors; carry rate-limit annotations through CreateAccount
- actions: switch the enable_user/disable_user user_id argument from a
free-form string to a ResourceIdField constrained to the user resource
type (C1 renders a resource picker); extract it via
actions.RequireResourceIDArg with a resource-type guard; prefix errors;
thread annotations
- test: pass user_id as a {resource_type_id, resource_id} struct to match
the new ResourceIdField arg shape
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds user account provisioning and reversible enable/disable actions to
baton-retool(CXH-1585). This augments the existing Postgres-only connector with a second auth surface (the Retool REST API) used only for the account lifecycle. Sync and the existing group/page provisioning stay 100% on Postgres and are unchanged.Changes
retool-api-base-url+retool-api-token(declaredFieldsRequiredTogether, both optional, token isWithIsSecret). Sync-only deployments keep working without them.uhttp) alongside the pgx pool, a singledoRequestchokepoint, andCreateUser/GetUserByEmail/SetUserActive+ValidateRESTagainst/api/v2/users.GetUserSIDresolves the synceduser:<int64>(legacy_id) to the RESTsidvia Postgres — no mutable-email join.AccountCreationSchemainMetadata(), a REST probe inValidate()(only when configured), andenable_user/disable_userglobal actions (ACTION_TYPE_ACCOUNT_ENABLE/ACCOUNT_DISABLE, modeled on baton-aws-cognito) backed byPATCH /api/v2/users/{id}on/active.CreateAccount(returns the durableuser:<legacy_id>straight from the create response, idempotent on duplicate email) andCreateAccountCapabilityDetails.*v2.LocalCredentialOptionsCreateAccount param; unlocksWithIsSecretand the action framework).ci.yaml(Retool stack + sync + grant/revoke + account lifecycle); golangci-lint migrated to v2 (v1 cannot target Go 1.25).connector.mdx, internaldocs/docs-info.md, plus the previously missingSELECT ("sid")grant for the dedicatedbatonPostgres user.Retool API semantics (verified against a live self-hosted instance)
/api/v2(not/api/v1).DELETE /api/v2/users/{id}returns 204 but only setsactive=false(row persists, memberships kept). Exposing that as an account Delete would misrepresent a reversible deactivation as a removal, so the connector models deprovisioning as thedisable_useraction, reversible viaenable_user(PATCHwith{"operations":[{"op":"replace","path":"/active","value":<bool>}]}). Both are idempotent.legacy_id → sidvia the Postgres pool it already holds, sidestepping the int64↔UUID mismatch entirely.Testing
Verified end-to-end against a self-hosted Retool 3.334.17:
sync(baseline) and group grant/revoke (existing path, unchanged) — pass.disable_user→ verify deactivated →enable_user→ verify reactivated → duplicate disable (idempotency) — pass.user_id, missing argument) fail cleanly with NotFound/InvalidArgument.capabilitiesadvertisesCAPABILITY_ACTIONS+CAPABILITY_ACCOUNT_PROVISIONING(no resource delete).The same lifecycle runs in CI on every push (
integration-testjob inci.yaml): it stands up a throwaway self-hosted Retool in Docker, seeds an admin, mints a REST token via SQL, and exercises sync + grant/revoke + the account lifecycle.go build,go vet, andgolangci-lint(v2) are clean.🤖 Generated with Claude Code