Pre-fix: webhook.Create / Update called only validateURL (scheme
check) and ssrf.Validate (scheme + port). Loopback / RFC1918 /
CGNAT / multicast hosts were rejected only at delivery time inside
dialContext — admin could persist a hook with http://localhost or
http://192.168.1.1 and only see failures on the deliveries page
after the first attempt. Disallowed ports were caught (port 9090
fails Validate); IPs were not.
Post-fix:
- ssrf.ValidateWithResolve runs Validate plus a DNS lookup +
IsForbiddenIP check on every resolved IP. IP literals are
matched directly without DNS. AllowedHosts and
AllowPrivateNetworks behave the same as in dialContext.
- webhook.Create + Update call ValidateWithResolve. The plain
Validate is left in place as the cheap syntactic gate.
- dialContext keeps re-resolving as defense in depth (DNS
rebinding) — the validate-resolve check is *not* a substitute.
ssrf_create_test pins the table directly: 127.0.0.1, [::1],
192.168.1.1, 10.0.0.1, 172.16.0.1, port 9090 — all rejected.
A public host on a default port still passes.
Pre-fix: webhook create/update overloaded ActionRepoCreated with
a meta.action discriminator. Webhook delete/toggle/ping/redeliver
were not audited at all — admin had no forensic record of who
disabled, deleted, or replayed a hook.
Post-fix:
- New audit actions: ActionWebhook{Created,Updated,Deleted,
ActiveSet,ActiveUnset,Pinged,Redelivered}.
- All 7 webhook handlers now audit, all attributed via
viewer.AuditActor so impersonation trails carry.
- Bonus: ActionAdminRepoForceUnarchived added in preparation for
SR2 H8 (split repoForceArchive into archive/unarchive).
Pre-fix: S30/S31 wired org/team handlers to call orgs.IsOwner
directly without going through policy.Can. A suspended user listed
as an org owner could still invite, change roles, remove members,
accept invitations, create teams, add team members, and grant
team→repo access — bypassing the suspension gate every other
mutation surface enforces. Same shape as SR1 C1, new surface.
Lighter fix per SR2 sprint plan: short-circuit on viewer.IsSuspended
at every point of entry. Heavier fix (real policy.Can actions for
ActionOrgInvite/ActionTeamMemberAdd/...) is correct architecturally
but is a larger refactor; left as forward-link.
Gated:
- requireOrgOwner — covers teamCreate, teamMemberAddRemove,
teamRepoGrant via the existing helper.
- invite — direct IsOwner caller, gated inline.
- memberMutate — direct IsOwner caller, gated inline (covers
changeRole + removeMember).
- invitationAction — accept/decline now denies suspended users.
Read-only IsOwner callers (teamsList, teamView, canSeeTeam,
filterSecretTeams) intentionally NOT gated — suspension blocks
writes, not reads (consistent with SR1).
Pre-fix: userResetPassword minted a token, persisted password_resets,
audited ActionAdminUserPasswordReset, then `_ = tokEnc` discarded
the token. admin.Deps had no email.Sender at all, so the comment
'surfaced via the email path' was false from the start. The audit
row claimed 'sent'; the user never received anything.
Post-fix:
- admin.Deps adds Email + Branding fields, mirroring auth.Deps.
- admin_wiring.buildAdminHandlers takes the same email.Sender
the auth surface uses (server.go threads pickEmailSender(cfg)
through both paths).
- userResetPassword calls email.ResetMessage + sender.Send. The
audit row now carries email_sent: bool plus email_error: string
on failure, so a stuck mailbox is visible in /admin/audit instead
of silently misleading.
- L6: server.go now passes version.Version into buildAdminHandlers
instead of the literal 'dev', so /admin/system reflects the
ld-flag-stamped build version.
admin_deps_test pins the field contract — removing Email or
Branding breaks compile.
Substitutes (viewer.ID, raw_meta) with viewer.AuditActor(raw_meta)
at every non-admin Audit.Record callsite in repo/settings_*.go and
repo/webhooks.go. During an impersonated session the row is now
attributed to the real admin and the impersonated user_id lands in
meta — uniform with the existing /admin/* trail.
admin/helpers.recordAdminAction also moves to AuditActor for one
canonical substitution point.
18 handler sites switched from policy.UserActor(viewer.ID,
viewer.Username, viewer.IsSuspended, false) to viewer.PolicyActor().
Includes profile, search, and repo (issues/PRs/lifecycle/social/
fork/labels/repo home) surfaces.
Pre-migration these all hardcoded IsSiteAdmin=false and ignored
viewer.ImpersonatedUserID. Post-migration impersonation is a
construct-time concern and admin-read-private gets a 200 instead
of 404.
PAT-bearing API handlers (api/checks.go, api/stars.go) and the
SSH/HTTPS-git paths keep plain UserActor() — those gates reject
suspended at credential check, and impersonation is impossible via
non-cookie auth, so the false literals are correct by construction.
Adds the canonical web-layer Actor constructor that propagates
IsSuspended, IsSiteAdmin, Impersonating, ImpersonateWriteOK from
the request-bound CurrentUser. Plain UserActor() left intact for
SSH/HTTP-git/worker callers that don't have a CurrentUser handy.
CurrentUser gains PolicyActor() and AuditActor(meta) helpers so
non-admin handlers can build actors and record audit rows with
uniform impersonation handling (SR2 H2).
actor_test pins:
- IsSuspended propagates (regression for SR1 C1)
- IsSiteAdmin propagates (regression for SR2 C2)
- Impersonating fires when ImpersonatedUserID != 0 (SR2 C1)
- ImpersonateWriteOK is honored (read-only-by-default guarantee)
- IsSiteAdmin stays false on the impersonated identity
Compares md5 of every file the ansible roles install against the
live droplet over a single ssh round-trip. TEMPLATE rows (those
rendered from .j2 with inventory vars) are reported with stat
info but not auto-diffed.
Run after any PR that touches deploy/ansible/ to surface what
needs to be pushed manually until we resolve the broader ansible
ownership question (issue #38).