Skip to content

fix: correct admin privilege escalation via team membership#3418

Open
Ziinc wants to merge 18 commits into
mainfrom
claude/fix-admin-status-check-IY1DR
Open

fix: correct admin privilege escalation via team membership#3418
Ziinc wants to merge 18 commits into
mainfrom
claude/fix-admin-status-check-IY1DR

Conversation

@Ziinc

@Ziinc Ziinc commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

This PR tightens up admin security

  • removes sharing of admin privileges with team members
  • adds grant/revoke flow for admin users
  • adds admin auth hook for ensuring that authz checks are performed on liveview mount
  • prevents removal of all admins and of self
  • add additional authz checks at each domain level
  • adds audit logging on all grant/revoke actions, under a new metadata.audit key

pre-merge todos:

  • verify on dev
  • adjust grant existing admins on prod

closes PRODSEC-36

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb

claude added 2 commits April 29, 2026 16:48
The query was joining Team and TeamUser, allowing any team member of
an admin user to pass the admin check. Admin status is now determined
solely by the authenticated user's own admin flag.

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb
Adds Admin.grant_admin/1, a POST route, controller action, and a
"make admin" button (with browser confirm dialog) next to the existing
"become" button for each non-admin account in /admin/accounts.

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb
claude added 2 commits April 30, 2026 10:13
Adds defense-in-depth: the business logic now verifies the granter has
admin: true, returning {:error, :unauthorized} if not. Combines the two
idempotency tests and adds a non-admin granter test.

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb
Comment thread lib/logflare_web/controllers/admin_controller.ex
claude added 5 commits May 4, 2026 02:53
…seError

Users.get/1 returns nil for unknown IDs. The nil clause now returns
{:error, :not_found} so the controller's error branch is always reached.

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb
- Guard become_account against nil user (prevents FunctionClauseError on unknown IDs)
- Add revoke_admin/2 with self-revocation guard, route, controller action, and UI button
- Add audit logging (granter/target IDs + emails) to grant_admin, revoke_admin, become_account
- Re-fetch granter from DB in grant_admin/revoke_admin controller actions to prevent stale-cache bypass

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb
- Add AdminAuth.on_mount/4 that re-checks Admin.admin?/1 from the
  session email on every LiveView mount/reconnect, closing the window
  where a revoked admin could still trigger ClusterLive shutdown events
- Wrap /admin/cluster and /admin/partner in live_session :admin using
  @common_on_mount_hooks ++ @admin_hooks
- Add nil-granter clauses to grant_admin/2 and revoke_admin/2 so a
  deleted-user granter returns {:error, :not_found} instead of crashing
- Handle {:error, :not_found} explicitly in both controller actions with
  an "Account not found." flash rather than the generic error

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb
Comment thread lib/logflare_web/controllers/admin_controller.ex
Comment thread lib/logflare/admin.ex Outdated
Comment thread lib/logflare_web/controllers/admin_controller.ex Outdated
Ziinc and others added 2 commits May 4, 2026 16:27
- Controller: replace Users.get(conn.assigns.user.id) with
  Users.get_by(email: get_session(conn, :current_email)) in grant_admin
  and revoke_admin so the acting admin is always the session owner, not
  the team-context owner that SetTeamContext places in conn.assigns.user
- Admin.revoke_admin/2: replace non-atomic count_admins()+Repo.update()
  with a transaction that locks all admin rows (FOR UPDATE) before
  counting, preventing two concurrent revocations from both observing
  count >= 2 and both committing

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb
Comment thread lib/logflare_web/controllers/admin_controller.ex Outdated
Comment thread lib/logflare/admin.ex
…fies granter in DB

Two security fixes:

1. become_account logged conn.assigns.user (set by SetTeamContext to the team
   owner) instead of the actual logged-in admin. Audit trails could attribute
   impersonation to an unrelated user. Now fetches the actor from the session
   email, consistent with grant_admin / revoke_admin.

2. revoke_admin passed an in-memory granter struct to the transaction. A window
   existed between Users.get_by/1 and the transaction where the granter's admin
   flag could be revoked, allowing a now-revoked admin to still revoke others.
   The transaction now re-checks granter's admin status from the locked DB
   snapshot before proceeding.

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb
Comment thread lib/logflare/admin.ex Outdated
Comment thread lib/logflare/admin.ex Outdated
Comment thread lib/logflare/admin.ex
claude added 2 commits May 4, 2026 15:24
Mirrors the pattern from revoke_admin: locks the granter's row with
FOR UPDATE before updating, so a revocation that races between the
controller's Users.get_by fetch and the Repo.update is detected and
rejected with {:error, :unauthorized} rather than silently succeeding.

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb
…_admin

Logger is not transactional. Previously, audit entries were written before
Repo.update/1, so a failed update (or rollback) would leave a permanent log
record of a privilege change that never persisted to the DB. Moved both
Logger.info calls into the {:ok, user} branch so they only fire when the
database write succeeds.

https://claude.ai/code/session_01UmrctTfmxLZVgEq91EKfGb
|> delete_session(:last_switched_team_id)
|> delete_resp_cookie("_logflare_user_id")
|> delete_resp_cookie("_logflare_team_user_id")
case Users.get(id) do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Severity: MEDIUM

The become_account action replaces an admin's session with an arbitrary user's session but is routed via GET. Phoenix's protect_from_forgery only covers non-GET verbs, and no SameSite cookie attribute is configured. An attacker can trigger impersonation by embedding <img src="/admin/accounts/VICTIM_ID/become"> on any page an authenticated admin visits.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Change the become_account route from GET to POST so that Phoenix's built-in CSRF protection applies to this state-mutating action. This requires two coordinated changes:

  1. In lib/logflare_web/router.ex (line 382), change:

    get("/accounts/:id/become", AdminController, :become_account)

    to:

    post("/accounts/:id/become", AdminController, :become_account)
  2. In lib/logflare_web/templates/admin/accounts.html.heex (line 35), add method: :post to the link helper so Phoenix automatically generates an inline form with a CSRF token:

    <small>{link("become", to: Routes.admin_path(@conn, :become_account, account.id), method: :post, data: [confirm: "Impersonate #{account.email}?"], class: "btn btn-danger btn-sm")}</small>

Using method: :post in Phoenix's link/2 helper causes it to render a small <form> with a hidden _csrf_token field, which means the protect_from_forgery plug will verify the token on every impersonation request. This prevents cross-site GET-based CSRF attacks (e.g., via <img src="/admin/accounts/VICTIM_ID/become">).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants