Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,20 @@ jobs:
/usr/local/lib/android \
/usr/share/dotnet
docker system prune --all --force --volumes
- name: Make host.docker.internal resolvable on the Linux runner
shell: bash
run: |
# Codefly's runtime health-checks its test dependencies (minio for
# object-storage, redis for cache, …) at host.docker.internal, the
# name Docker Desktop provides on macOS/Windows. Linux runners do
# not resolve it on the host, and a runner-image change dropped it,
# breaking the frontend test phase with
# minio is not ready: … lookup host.docker.internal … no such host
# Published dependency ports bind to the host loopback, so map the
# name there. Idempotent: skip if already present.
if ! grep -q 'host.docker.internal' /etc/hosts; then
echo "127.0.0.1 host.docker.internal" | sudo tee -a /etc/hosts
fi
- name: Run Codefly-owned gate
shell: bash
env:
Expand Down
3 changes: 3 additions & 0 deletions configurations/local-dogfood/identity.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ IDENTITY_JWKS_URL=https://api.workos.com/sso/jwks/client_REPLACE_ME
IDENTITY_AUTHORIZE_SELECTOR=authkit
# Optional fallback for traffic that intentionally bypasses auth-sidecar.
IDENTITY_ALLOWED_REDIRECT_URIS=
# Gate signup only (login and invite are never gated): open | invite | waitlist.
# Defaults to open when unset; unrecognised values fail startup closed.
IDENTITY_SIGNUP_MODE=open
7 changes: 7 additions & 0 deletions configurations/local/identity.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@
# The fixture name is data only; this configuration selects the authentication
# adapter explicitly. Real-provider dogfooding uses the dogfood profile.
IDENTITY_PROVIDER=fixture

# IDENTITY_SIGNUP_MODE gates signup only; login and invite are never gated.
# open — any authenticated identity may sign up (default)
# invite — signup requires a pending invitation for the verified email
# waitlist — signup requires an approved or invited waitlist entry
# Unrecognised values fail startup closed.
IDENTITY_SIGNUP_MODE=open
1 change: 1 addition & 0 deletions module/services/accounts/code/pkg/auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ var (
ErrUnknownIdentity = errors.New("auth: identity not found")
ErrNoAccount = errors.New("auth: no account for this identity")
ErrAccountInactive = errors.New("auth: account is not active")
ErrSignupNotAllowed = errors.New("auth: signup is not permitted for this identity")
ErrOrgRequired = errors.New("auth: org required for this operation")
ErrBootstrapClaimed = errors.New("auth: bootstrap already claimed")

Expand Down
184 changes: 157 additions & 27 deletions module/services/accounts/code/pkg/auth/pg/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const BootstrapAdminEmailEnv = "BOOTSTRAP_ADMIN_EMAIL"
type Resolver struct {
bootstrap BootstrapStore
bootstrapAdminEmail *string
signupMode auth.SignupMode
}

// BootstrapStore is the narrow pre-auth database capability. It deliberately
Expand All @@ -64,6 +65,13 @@ func (r *Resolver) SetBootstrapAdminEmail(email string) {
r.bootstrapAdminEmail = &normalized
}

// SetSignupMode selects the access policy applied to first-seen identities. The
// zero value (open) is the default, so a Resolver constructed without this
// setter provisions every authenticated identity as before.
func (r *Resolver) SetSignupMode(mode auth.SignupMode) {
r.signupMode = mode
}

// Resolve implements auth.IdentityResolver.
func (r *Resolver) Resolve(ctx context.Context, c *auth.Claims, intent auth.Intent) (*auth.Identity, error) {
if err := c.Valid(); err != nil {
Expand Down Expand Up @@ -119,13 +127,7 @@ func (r *Resolver) resolveInTx(
}
orgID, orgRole, err = r.selectOrg(ctx, tx, userID, c.ProviderOrgID)
case auth.SignupIntent:
if !found {
userID, err = r.provisionIdentity(ctx, tx, c)
if err != nil {
return nil, err
}
}
orgID, orgRole, err = r.ensureOrg(ctx, tx, userID, c.ProviderOrgID, intent.OrganizationName)
userID, orgID, orgRole, err = r.resolveSignup(ctx, tx, c, userID, found, intent.OrganizationName)
case auth.InviteIntent:
userID, orgID, orgRole, err = r.resolveInvite(ctx, tx, c, userID, found, intent.Token)
default:
Expand Down Expand Up @@ -372,6 +374,152 @@ func (r *Resolver) ensureOrg(
return orgID, "owner", nil
}

// resolveSignup provisions and situates a first-seen identity according to the
// configured SignupMode. An already-existing identity (found) is never gated:
// it resolves like a login through the signup path, so gating never locks out
// established users. Only genuine provisioning of a new identity is gated.
func (r *Resolver) resolveSignup(
ctx context.Context,
tx pgx.Tx,
c *auth.Claims,
userID uuid.UUID,
found bool,
orgName string,
) (uuid.UUID, uuid.UUID, string, error) {
if !found {
switch r.signupMode {
case auth.SignupModeInvite:
return r.provisionInvitedSignup(ctx, tx, c)
case auth.SignupModeWaitlist:
if err := r.requireApprovedWaitlist(ctx, tx, c); err != nil {
return uuid.Nil, uuid.Nil, "", err
}
}
var err error
userID, err = r.provisionIdentity(ctx, tx, c)
if err != nil {
return uuid.Nil, uuid.Nil, "", err
}
}

orgID, orgRole, err := r.ensureOrg(ctx, tx, userID, c.ProviderOrgID, orgName)
return userID, orgID, orgRole, err
}

// provisionInvitedSignup authorises a first-seen identity under invite mode.
// Signup is permitted only when a pending, unexpired invitation matches the
// verified email; the invitee is then provisioned and bound to the inviting org,
// exactly as accepting the invitation by token would. A stranger with no
// invitation is rejected before any row is written.
func (r *Resolver) provisionInvitedSignup(
ctx context.Context,
tx pgx.Tx,
c *auth.Claims,
) (uuid.UUID, uuid.UUID, string, error) {
// Binding an invitation authorises on email equality, so the address must be
// one the provider verified — otherwise an unverified claim could inherit an
// organization addressed to an email it has not proven it controls. Checked
// before the lookup so an unverified caller cannot probe which emails have a
// pending invitation; the response is indistinguishable from "not invited".
if !c.EmailVerified {
return uuid.Nil, uuid.Nil, "", auth.ErrSignupNotAllowed
}

var invID, orgID uuid.UUID
var role string
// The expiry predicate lives in SQL so a newer but expired invitation cannot
// shadow an older still-valid one: only unexpired invitations are candidates,
// and the most recent of those wins. CURRENT_TIMESTAMP also compares against
// the database clock rather than this process's, matching the rest of the
// invitation queries.
err := tx.QueryRow(ctx, `
SELECT id, org_id, role
FROM invitations
WHERE LOWER(email) = LOWER($1) AND status = 'pending'
AND expires_at > CURRENT_TIMESTAMP
ORDER BY created_at DESC
LIMIT 1
FOR UPDATE`,
c.Email,
).Scan(&invID, &orgID, &role)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, uuid.Nil, "", auth.ErrSignupNotAllowed
}
if err != nil {
return uuid.Nil, uuid.Nil, "", fmt.Errorf("pgauth: load signup invitation: %w", err)
}

userID, err := r.provisionIdentity(ctx, tx, c)
if err != nil {
return uuid.Nil, uuid.Nil, "", err
}
if err := r.acceptInvitation(ctx, tx, invID, orgID, userID, role); err != nil {
return uuid.Nil, uuid.Nil, "", err
}
return userID, orgID, role, nil
}

// requireApprovedWaitlist authorises a first-seen identity under waitlist mode.
// Signup is permitted only when a waitlist entry for the verified email has been
// approved or invited; pending, rejected, and absent entries are rejected.
//
// It takes the whole Claims rather than a bare email because the authorisation
// turns on email equality: the verification fact must travel with the address,
// so an unverified claim cannot match a waitlist entry it has not proven it owns.
func (r *Resolver) requireApprovedWaitlist(ctx context.Context, tx pgx.Tx, c *auth.Claims) error {
// Fail closed before the lookup so an unverified caller cannot probe which
// emails hold an approved waitlist entry.
if !c.EmailVerified {
return auth.ErrSignupNotAllowed
}

var state string
err := tx.QueryRow(ctx, `
SELECT state FROM waitlist_entries
WHERE normalized_email = LOWER(BTRIM($1))`,
c.Email,
).Scan(&state)
if errors.Is(err, pgx.ErrNoRows) {
return auth.ErrSignupNotAllowed
}
if err != nil {
return fmt.Errorf("pgauth: load waitlist state: %w", err)
}
if state != "approved" && state != "invited" {
return auth.ErrSignupNotAllowed
}
return nil
}

// acceptInvitation joins userID to the invitation's org with the invitation's
// role and marks the invitation accepted. The membership upsert mirrors
// AddOrgMember: the accepted invitation's role is authoritative, so it wins over
// any pre-existing membership rather than leaving a stale role behind.
func (r *Resolver) acceptInvitation(
ctx context.Context,
tx pgx.Tx,
invID, orgID, userID uuid.UUID,
role string,
) error {
if _, err := tx.Exec(ctx, `
INSERT INTO organization_members (org_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (org_id, user_id) DO UPDATE SET role = $3`,
orgID, userID, role,
); err != nil {
return fmt.Errorf("pgauth: add invited member: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE invitations
SET status = 'accepted', accepted_at = NOW(), accepted_by = $2
WHERE id = $1 AND status = 'pending'`,
invID, userID,
); err != nil {
return fmt.Errorf("pgauth: accept invitation: %w", err)
}
return nil
}

// resolveInvite authenticates against a pending invitation. Ordering is
// security-relevant: the invitation must be located, be pending and unexpired,
// and match the authenticated email BEFORE any user row is provisioned. Only
Expand Down Expand Up @@ -443,26 +591,8 @@ func (r *Resolver) resolveInvite(
}
}

// Mirror AddOrgMember: the accepted invitation's role is authoritative, so
// upsert it on conflict rather than leaving an existing membership's role
// behind. Otherwise the role returned below (and minted into the JWT) could
// disagree with the persisted membership.
if _, err := tx.Exec(ctx, `
INSERT INTO organization_members (org_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (org_id, user_id) DO UPDATE SET role = $3`,
orgID, userID, role,
); err != nil {
return uuid.Nil, uuid.Nil, "", fmt.Errorf("pgauth: add invited member: %w", err)
}

if _, err := tx.Exec(ctx, `
UPDATE invitations
SET status = 'accepted', accepted_at = NOW(), accepted_by = $2
WHERE id = $1 AND status = 'pending'`,
invID, userID,
); err != nil {
return uuid.Nil, uuid.Nil, "", fmt.Errorf("pgauth: accept invitation: %w", err)
if err := r.acceptInvitation(ctx, tx, invID, orgID, userID, role); err != nil {
return uuid.Nil, uuid.Nil, "", err
}

return userID, orgID, role, nil
Expand Down
Loading
Loading