Egret Nest Dashboard - Authentication, Authorization & Security Design
Why this matters: the dashboard stores a company’s egress / supply-chain graph - what every CI job connected to, which processes ran, which files were touched. That is among the most sensitive telemetry an org has. The dashboard is therefore designed and reviewed as a crown-jewel application: least privilege, defense in depth, and a security + pentest gate before any shared deployment.
This document is the source of truth for the dashboard’s auth and security design.
1. Authentication - one core, three pluggable providers
We do not build three auth systems. We build one provider-agnostic core and
three swappable providers. A deployer enables any combination via config -
environment variables (authoritative) or the admin Authentication page
(/admin/auth), which stores GitHub/OIDC client secrets encrypted at rest
(EGRET_NEST_SECRET_KEY) and hot-reloads the provider; env always takes precedence.
┌──────────────────────────────────────────┐
browser ──▶ │ session middleware (cookie ⇄ Session) │
└───────────────┬──────────────────────────┘
│ establishes identity via one of:
┌─────────────────────┼──────────────────────────┐
▼ ▼ ▼
GitHub OAuth Generic OIDC Local accounts
(Egret App) (Okta/Entra/Google) (argon2id + TOTP 2FA)
└─────────────────────┴──────────────────────────┘
▼
Users · Orgs · Memberships · Roles (shared core)
Provider interface (conceptual)
type Provider interface {
ID() string // "github" | "oidc" | "local"
// Begin returns a redirect URL (OAuth/OIDC) or renders a form (local).
Begin(w, r) error
// Complete verifies the callback/credentials and returns the external identity.
Complete(w, r) (ExternalIdentity, error)
}
type ExternalIdentity struct {
Provider string
Subject string // stable provider user id
Login string // display handle / email
Email string
// For GitHub: the orgs/repos this identity can access (for authz sync).
GitHubOrgs []string
}
The core maps an ExternalIdentity to a local User, creates a Session, and
sets the cookie. Providers never touch sessions directly.
1a. GitHub OAuth (recommended default)
- OAuth via the Egret App (same App used for the CI-side token - one integration, two jobs). Needs the App’s OAuth client id/secret in config.
- Inherits GitHub’s 2FA, org-enforced MFA, and SAML SSO - we build no MFA.
- The access token is used once at login to verify the user is a current member of the configured allowed org (revoked members lose access on their next login), then discarded (not stored long-term). Passing this check provisions a local account with no org membership - see §2; it does not sync repo access.
1b. Generic OIDC
- Standard Authorization Code + PKCE against any OIDC IdP (Okta, Entra, Google Workspace, Keycloak…). MFA is enforced by the IdP.
- Config: issuer URL, client id/secret, scopes. Uses
coreos/go-oidcfor discovery + ID-token verification (vetted dependency).
1c. Local accounts (air-gapped)
- Username + password hashed with argon2id (
golang.org/x/crypto/argon2; per-user random salt; tuned params). Never bcrypt-with-defaults, never sha. - TOTP 2FA (RFC 6238) - enrollable + enforceable per-org. WebAuthn/passkeys are a later addition.
- Includes the first-run bootstrap admin (printed one-time setup token) so a fresh install is reachable before any IdP is configured.
- Protections: login rate-limiting + lockout, constant-time compares, generic “invalid credentials” errors (no user enumeration), password-strength check.
2. Identity & authorization model
Organization 1──* Membership *──1 User
Organization 1──* Repository 1──* Run
- Two privilege axes. (1) An instance-wide
is_adminflag - set only on the first-run bootstrap admin (no handler ever mutates it, so there is no in-app escalation to it). Instance admins reach the instance console (/admin/*: users, settings, logs, audit) and act with owner-level authority in every org. (2) A per-org membership role (owner>admin>member>viewer), enforced byRole.AtLeast. - Org-scoped management (enforced). An org owner/admin self-manages their
own org at
/org/{id}/tokensand/org/{id}/members- creating/revoking that org’s ingest tokens and granting/revoking that org’s memberships - without the instanceis_adminflag.member/viewerhave no management power (view only). The delegation is deliberately conservative and covered by tests:- a manager may never assign a role above their own effective role, nor act
on a member who outranks them (an
admincannot touch anowner); - the last owner of an org can never be removed or downgraded;
- a manager only ever sees/acts within the org in the path - token revoke verifies the token belongs to that org (no cross-org id-guessing), and the member list is scoped to that single org (no instance-wide user enumeration);
- the
/org/{id}/*routes 404 (existence hidden) for non-managers, like the instance admin routes. The last-owner and cross-org guards are enforced atomically in the store (SetMembershipRole/RemoveMembershipGuarded/RevokeIngestTokenInOrg), not in per-handler branches, so no code path or concurrent request can bypass or race them. Accepted tradeoff: “add member by login” reveals whether a login exists instance-wide (an existence oracle) - judged low risk because the caller is already an authenticated org owner/admin, and org-admin is itself granted by an instance admin.
- a manager may never assign a role above their own effective role, nor act
on a member who outranks them (an
- Access is org membership, granted explicitly (fail-closed). A user sees a
run iff they hold a membership in the org that owns the run’s repo. SSO
(GitHub/OIDC) authenticates who you are - it does not auto-grant access to
any tenant. A freshly-provisioned SSO user lands with zero memberships and
can see nothing until an instance admin grants them an org on
/admin/users. This is deliberate: passing the GitHub-org login gate is authentication, not authorization to every connected repo’s egress telemetry.Not (yet) implemented: automatic mirroring of GitHub repo-level access onto dashboard visibility. That was specified but shipping it silently would have made any org member a reader of every connected repo, so provisioning is fail-closed instead. Per-repo GitHub-access mirroring is future work; until then, tenant access is an explicit admin grant.
- Enforcement: every handler runs through an authz guard keyed on
(user, org, action); queries are org-scoped. Default deny. No object is returned
without an explicit access check - no IDOR:
/runs/{id}verifies the run’s repo belongs to an org the caller is a member of.
3. Sessions & CSRF
- Server-side sessions (row in SQLite), opaque random 256-bit id.
- Cookie:
HttpOnly,Secure,SameSite=Lax,__Host-prefix, no domain. - Idle timeout (e.g. 12h) + absolute lifetime (e.g. 7d); rotate id on login.
- CSRF: double-submit token on every state-changing request (POST/PUT/DELETE);
SameSite=Laxis defense-in-depth, not the only control. - Logout revokes the server session; “log out everywhere” clears all sessions.
4. Ingest & webhook authentication
- Ingest uses per-org / per-repo scoped tokens - created in the UI, hashed at
rest, revocable, last-used tracked. A leaked token exposes one repo’s ingest, not
the whole instance.
EGRET_NEST_TOKENremains a legacy shared fallback. - GitHub webhooks (if enabled) are verified by HMAC-SHA256 over the raw body using the webhook secret; unverified deliveries are rejected.
- Ingest is authn-only (no session) and rate-limited per token.
5. Hardening (defense in depth)
- TLS-first: native TLS (
EGRET_NEST_TLS_CERT/_TLS_KEY, TLS 1.2+) or a TLS-terminating proxy (EGRET_NEST_BEHIND_PROXY=1); set HSTS. Refuse to setSecurecookies over plain HTTP except on localhost.r.TLS/trustedX-Forwarded-Protodrives theSecure/__Host-cookie attributes. - Security headers on every response:
Content-Security-Policydefault-src 'none'; style-src 'self'; script-src 'none'; img-src 'self' data:(server-renderedhtml/template, no JS at all - no inline scripts, no nonce, htmx not yet used),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer,Permissions-Policy. - Audit log: append-only record of security-relevant events (login, token create/revoke, role change, settings change, data export) with actor + time.
- Request size limits (already on ingest), timeouts, and panic-recovery middleware.
- Secrets at rest: provider client-secrets and webhook secrets read from env or files, never logged; token/password material stored hashed only.
- No PII beyond GitHub/OIDC logins and emails; retention/pruning configurable.
- Supply chain: pure-Go, pinned +
go mod verify+govulncheck(see DEPENDENCIES.md).
6. New dependencies (all to be vetted per DEPENDENCIES.md)
| Dependency | For | Notes |
|---|---|---|
golang.org/x/crypto |
argon2id password hashing | Go team; permissive |
coreos/go-oidc (+ golang.org/x/oauth2) |
OIDC / OAuth flows | de-facto OIDC lib; add only with provider 1a/1b |
| TOTP | local 2FA | prefer a small stdlib RFC-6238 impl (crypto/hmac); else vet pquerna/otp |
go-webauthn/webauthn |
passkeys (later) | deferred |
Everything else stays stdlib (crypto/*, net/http, database/sql, html/template).
7. Build sequence
- Security core (no external IdP needed - buildable/testable now): identity
schema (users/orgs/memberships/roles/sessions/tokens/audit), session + CSRF +
security-headers middleware, authz guard, local provider (argon2id + TOTP)
- bootstrap admin, scoped ingest tokens.
- GitHub OAuth provider - drops in once the Egret App exists (needs OAuth creds); adds repo-access authz sync.
- OIDC provider - for BYO-IdP enterprises.
- Gate: a full security review before any shared/production deployment; fix findings; only then document a public deploy.
Steps 1 is fully testable on any machine (no kernel, no App). Steps 2-3 add providers behind the same interface without touching the core.