Commit Graph
6 Commits
Author SHA1 Message Date
steveandClaude Opus 4.8 5dd35c3800 Copy a garden (deep duplicate) from the gardens list
Build image / build-and-push (push) Successful in 23s
Gadfly review (reusable) / review (pull_request) Successful in 6m30s
Adversarial Review (Gadfly) / review (pull_request) Successful in 6m30s
Adds POST /gardens/:id/copy: duplicates a garden the actor owns, along
with its objects and their currently-planted plops, into a new garden
owned by the actor. A blank name derives "<source> (copy)".

Deliberately not carried over:
  * public_token — the share link is a capability granted to the
    original; a copy must not silently inherit a live public URL
  * garden_shares — a copy is private to whoever made it
  * removed plantings — the copy is a fresh layout, not a history

Owner-only for now: a copy's plops keep pointing at the SOURCE's
plant_ids, so letting a viewer copy someone else's garden would hand
them plants outside their catalog and pin the original owner's plants
against deletion (plantings.plant_id is ON DELETE RESTRICT). Copying a
shared garden needs a plant-cloning policy first.

The whole copy runs in one transaction, so a failure part-way leaves no
half-populated garden. The object/planting list scans are factored into
queryObjects/queryPlantings so the same code serves a plain read and a
read inside that transaction.

UI: a Copy action on the owner's garden card opens a modal prefilled
with the derived name, then lands in the new garden.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 23:49:50 -04:00
steve 293532f021 Plant catalog backend: CRUD + seeded built-ins (#12)
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 01:48:22 +00:00
steveandClaude Opus 4.8 8ef092713f Address Gadfly review on #5: OIDC identity guard, verified-email, timeouts
Build image / build-and-push (push) Successful in 9s
Fixes from the PR #24 adversarial review (graded 23 real / 1 false positive):

Security / correctness
- LinkOIDC no longer overwrites a different stored identity: the UPDATE
  matches only when the row has no identity yet or already carries this
  exact one, so a second IdP asserting the same verified email can't
  hijack or lock out an account (returns ErrOIDCIdentityConflict). Uses
  a single UPDATE...RETURNING (also fixes the ignored-RowsAffected /
  misleading-ErrNotFound path and the round-trip).
- Provisioning now requires a verified email for BOTH linking and JIT
  creation (was: linking only), so an unverified-email identity can't
  create an account — nor become the first admin on a fresh instance,
  nor squat an email a real user later owns.
- OIDC-identity collisions surface as the dedicated ErrOIDCIdentityConflict
  instead of the email-specific ErrEmailTaken.

Robustness
- readOIDCTxCookie requires a non-empty nonce (an empty one would make the
  callback's nonce check pass vacuously).
- Callback token exchange + verify run under a 15s context timeout so a
  slow IdP can't outlast the server write timeout.
- ensure() performs discovery outside the mutex, so concurrent cold-start
  requests don't serialize behind one another's full timeout.
- setOIDCTxCookie returns its marshal error; oidcLogin aborts rather than
  redirecting to the IdP with no tx cookie.

Maintainability
- redirectAuthError helper dedups the ~dozen callback redirects (and the
  empty-code path now logs like the rest).
- Distinct login error codes (no_email / email_unverified / oidc_conflict)
  for the UI; writeServiceError maps the OIDC sentinels; shared test issuer
  const.

Tests: unverified email refused for both link and JIT; identity-overwrite
refused while the original identity keeps working.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:33:25 -04:00
steveandClaude Opus 4.8 84edf3e42a Add OIDC login via Authentik: PKCE, JIT provisioning, email linking (#5)
Build image / build-and-push (push) Successful in 5s
Gadfly review (reusable) / review (pull_request) Successful in 9m32s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m32s
OIDC is pansy's primary login path (Authentik the target IdP); local
auth (#4) remains the fallback and both issue the same session cookie.

- deps: github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 (both pure Go;
  CGO stays off).
- api/oidc.go: lazy issuer discovery (retried per-request, never crashes
  a server that also serves local auth), GET /auth/oidc/login builds an
  authorization-code URL with PKCE S256 + random state + nonce stashed in
  a short-lived HttpOnly cookie, GET /auth/oidc/callback verifies state
  (constant-time), exchanges the code with the PKCE verifier, verifies the
  ID token + nonce, and starts a pansy session. Failures redirect to
  /login?error=... ; success to /gardens.
- service.LoginOIDC: (issuer,subject) match -> login; else *verified*
  email match -> link onto the existing account; else JIT-create (the IdP
  gates access, so PANSY_REGISTRATION doesn't apply). Unverified email
  colliding with an existing account is refused (takeover guard); no email
  is refused (email is the account key). Reuses the atomic CreateUser.
- store: GetUserByOIDC + LinkOIDC (unique-pair backstop).
- config: OIDCReady() (needs issuer+client+BaseURL for the redirect URI);
  /auth/providers now reports oidc from it and defaults the button label
  to "Sign in with Authentik". OIDC routes are only registered when ready,
  so an unconfigured instance 404s them.
- PANSY_LOCAL_AUTH=false rejects/hides local auth but not OIDC.

Tests: service provisioning (JIT, repeat login, link, unverified-collision
refusal, no-email, name fallback, works with local auth off); api
(routes-absent-when-unconfigured, providers reporting, login redirect with
PKCE params + tx cookie via a fake discovery server, callback state/error
paths). Smoke-tested: unreachable issuer degrades to error=oidc_unavailable
with the server still up and local auth working.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:13:47 -04:00
steveandClaude Opus 4.8 02c928ac6d Address Gadfly review on #4: TOCTOU, sliding cookie, CSRF, argon2
Build image / build-and-push (push) Successful in 5s
Fixes from the PR #23 adversarial review (graded 35 real / 1 false positive):

Security / correctness
- Race-free registration: is_admin and the registration gate are now
  computed atomically inside a single INSERT...SELECT, so concurrent
  first registrations can't both become admin or bypass closed
  registration (fixed the whole TOCTOU cluster).
- Sliding session now reaches the browser: ResolveSession returns the
  current expiry and requireAuth re-sets the cookie, so active users
  aren't logged out 30 days after login regardless of activity.
- Login CSRF: csrfGuard rejects state-changing requests whose Origin
  doesn't match PANSY_BASE_URL (no-op when unset, so the dev proxy is
  unaffected). SameSite=Lax alone didn't cover this.
- argon2id tuned to RFC 9106's second recommended profile (t=3).
- Timing equalizer can't fail open: the dummy hash is derived
  deterministically (fixed salt, no RNG) so it's always present.
- Password length (<=1024) enforced in the service for both register
  and login, not just HTTP binding tags; login rejects over-long input
  before spending argon2 work.

Error handling / robustness
- Login logs a malformed stored hash instead of silently treating it as
  a wrong password.
- Best-effort session writes (Touch/Delete during renewal, expiry, and
  corrupt-expiry cleanup) now log on failure.
- index sessions.expires_at via new migration 0002 (0001 is immutable).

Maintainability
- Extract startSessionAndRespond and abortUnauthenticated; make
  writeServiceError a free function; consistent error handling in
  decodeHash; doc/comment fixes.

Tests: over-long password, CSRF guard (cross-origin/same-origin/dev
no-op), and cookie refresh on authenticated requests; migration-version
assertions bumped to 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:04:35 -04:00
steveandClaude Opus 4.8 0e41ccd95a Add local auth: users, sessions, register/login/logout/me (#4)
Build image / build-and-push (push) Successful in 8s
Gadfly review (reusable) / review (pull_request) Successful in 7m7s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m7s
Implements pansy's local (email + password) authentication and the
session layer that OIDC (#5) will also reuse.

- store: users.go (create/get-by-id/get-by-email/count) and sessions.go
  (create/get/touch/delete/delete-expired), scanning the existing 0001
  schema.
- service: the business-logic seam. auth.go (Register/Login/session
  lifecycle/Providers) + password.go (argon2id, 64 MiB/1/4, PHC-encoded,
  constant-time verify) + service.go (Service, clock injection, token
  hashing). First user is admin; closed registration still allows the
  bootstrap user; unknown-email and wrong-password are indistinguishable
  (same error, same argon2 work via a dummy hash).
- api: POST /auth/register|login|logout, GET /auth/me|providers, plus a
  requireAuth middleware that resolves the HttpOnly session cookie
  (SameSite=Lax, Secure under https) to the actor. Handlers stay thin.
- main: wires the service and a periodic expired-session sweep; sessions
  are also dropped lazily on access. Sliding 30-day expiry.
- tests: service (register/login/expiry/renewal/cleanup, password) and
  api (cookie flow, middleware, validation, providers).

Verified end-to-end via curl: register -> me -> restart -> session
persists -> logout -> 401.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 16:38:03 -04:00