orgsso

package
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package orgsso owns the per-tenant (per-org) SSO provider configuration: the rows an iterion org admin self-serves to enable login via their own Keycloak (a discovery-based OIDC provider) or to gate the deployment-level GitHub login on specific GitHub teams.

It mirrors the per-tenant forge OAuth-app store (pkg/forge/oauth_app_store.go): one unified collection, rows discriminated by Kind, client secrets sealed at rest via secrets.Sealer with AAD "org_sso_provider:<id>". Get is keyed by id only — the HTTP layer asserts tenant ownership before mutating.

Security posture (see docs/cloud-admin.md): an org-admin-supplied issuer URL drives server-side discovery/token/userinfo fetches, so every outbound call is routed through the shared SSRF guard (pkg/secure/httpdial). SSO grant roles can never be `owner` (a static invariant enforced in Validate) and the dynamic ceiling (≤ the configuring admin's role) is enforced by the route handler.

Index

Constants

View Source
const SSOProvidersCollectionName = "org_sso_providers"

SSOProvidersCollectionName is the Mongo collection backing the per-tenant SSO provider rows.

View Source
const VerifiedDomainsCollectionName = "org_verified_domains"

VerifiedDomainsCollectionName is the Mongo collection backing tenant domain claims.

Variables

View Source
var (
	ErrDomainNotFound = errors.New("orgsso: domain not found")
	ErrDomainExists   = errors.New("orgsso: domain already claimed for this org")
	ErrDomainInvalid  = errors.New("orgsso: invalid domain")
)
View Source
var (
	ErrNotFound      = errors.New("orgsso: provider not found")
	ErrExists        = errors.New("orgsso: a github provider already exists for this org")
	ErrInvalid       = errors.New("orgsso: invalid provider definition")
	ErrOwnerNotGrant = errors.New("orgsso: owner cannot be granted via SSO")
)

Sentinel errors. The HTTP layer maps these to status codes.

Functions

func EmailDomain

func EmailDomain(email string) string

EmailDomain extracts the lowercased domain from an email address.

func FlattenGitHubTeamKeys

func FlattenGitHubTeamKeys(grants []GitHubTeamGrant) []string

FlattenGitHubTeamKeys derives the materialised GitHubTeamKeys from a grant list (lowercased, deduped). Wildcard grants collapse to "<org>/*". Only VERIFIED grants are included — an unverified grant (the team hasn't proven it controls that GitHub org) is stored but inert at login.

func NewDomainToken

func NewDomainToken() (string, error)

NewDomainToken returns a random challenge token (~32 bytes base64url).

func NormalizeDomain

func NormalizeDomain(s string) string

NormalizeDomain lowercases + trims a domain, stripping a leading "@" or "*." and any scheme/path an admin might paste.

func OpenClientSecret

func OpenClientSecret(sealer secrets.Sealer, providerID string, sealed []byte) (string, error)

OpenClientSecret returns an OIDC provider's client_secret from its sealed blob.

func ParseOIDCSlug

func ParseOIDCSlug(slug string) (id string, ok bool)

ParseOIDCSlug extracts the provider ID from a per-org OIDC slug. ok is false for global connector slugs (which the caller resolves from the static registry instead).

func SealClientSecret

func SealClientSecret(sealer secrets.Sealer, providerID, clientSecret string) ([]byte, error)

SealClientSecret seals an OIDC provider's client_secret, binding the sealed blob to the provider id via AAD "org_sso_provider:<id>" (same convention as forge_oauth_app:<id> / generic_secret:<id>) so a sealed payload can't be silently transplanted onto another provider record or tenant.

func VerifyDomainTXT

func VerifyDomainTXT(ctx context.Context, lookup TXTLookupFunc, d VerifiedDomain) (bool, error)

VerifyDomainTXT reports whether the domain's challenge TXT record is present.

Types

type DomainStore

type DomainStore interface {
	Create(ctx context.Context, d VerifiedDomain) error
	Get(ctx context.Context, id string) (VerifiedDomain, error)
	Update(ctx context.Context, d VerifiedDomain) error
	Delete(ctx context.Context, id string) error
	ListByTenant(ctx context.Context, tenantID string) ([]VerifiedDomain, error)
	// IsVerifiedForTenant reports whether domain is a VERIFIED claim of tenantID
	// — the auto-link gate's lookup.
	IsVerifiedForTenant(ctx context.Context, tenantID, domain string) (bool, error)
	// TenantsForDomain returns the tenant ids that have VERIFIED the domain —
	// the login screen's "discover an org's SSO from the user's email domain"
	// lookup. Returns an empty slice (never an error) for an unclaimed domain so
	// the providers endpoint stays a non-oracle.
	TenantsForDomain(ctx context.Context, domain string) ([]string, error)
}

DomainStore persists per-tenant verified-domain claims. Get is keyed by id only — the HTTP layer asserts tenant ownership before mutating.

type GitHubTeamGrant

type GitHubTeamGrant struct {
	GitHubOrg   string        `bson:"github_org" json:"github_org"`
	GitHubOrgID int64         `bson:"github_org_id,omitempty" json:"github_org_id,omitempty"`
	TeamSlug    string        `bson:"team_slug,omitempty" json:"team_slug,omitempty"`
	TeamID      int64         `bson:"team_id,omitempty" json:"team_id,omitempty"`
	Role        identity.Role `bson:"role" json:"role"`
	// Verified is set by the route handler once the org has proven control of
	// GitHubOrg (Phase 2: a verified forge connection). An unverified grant is
	// stored but inert at login time — surfaced as "pending verification".
	Verified bool `bson:"verified,omitempty" json:"verified"`
}

GitHubTeamGrant maps a (GitHub org, team) to an iterion role. TeamSlug "" or "*" matches any team in (i.e. plain membership of) the GitHub org. Numeric IDs are captured for stable matching across org/team renames; the login path matches on IDs when present, falling back to lowercased login/slug.

type Kind

type Kind string

Kind discriminates the two provider shapes stored in the unified collection.

const (
	// KindOIDC is a per-org discovery-based OIDC provider (Keycloak, Auth0,
	// Azure AD, …). Carries issuer URL + client credentials.
	KindOIDC Kind = "oidc"
	// KindGitHub gates the deployment-level GitHub login on allow-listed
	// GitHub teams. Carries no credentials of its own (it reuses the global
	// GitHub OAuth app); only the grant list + auto-provision policy.
	KindGitHub Kind = "github"
)

func (Kind) Valid

func (k Kind) Valid() bool

Valid reports whether k is a known kind.

type MemoryDomainStore

type MemoryDomainStore struct {
	// contains filtered or unexported fields
}

func NewMemoryDomainStore

func NewMemoryDomainStore() *MemoryDomainStore

func (*MemoryDomainStore) Create

func (*MemoryDomainStore) Delete

func (m *MemoryDomainStore) Delete(_ context.Context, id string) error

func (*MemoryDomainStore) Get

func (*MemoryDomainStore) IsVerifiedForTenant

func (m *MemoryDomainStore) IsVerifiedForTenant(_ context.Context, tenantID, domain string) (bool, error)

func (*MemoryDomainStore) ListByTenant

func (m *MemoryDomainStore) ListByTenant(_ context.Context, tenantID string) ([]VerifiedDomain, error)

func (*MemoryDomainStore) TenantsForDomain

func (m *MemoryDomainStore) TenantsForDomain(_ context.Context, domain string) ([]string, error)

func (*MemoryDomainStore) Update

type MemoryStore

type MemoryStore struct {
	// contains filtered or unexported fields
}

MemoryStore is an in-process Store for tests and local mode.

func NewMemoryStore

func NewMemoryStore() *MemoryStore

NewMemoryStore returns an empty in-memory store.

func (*MemoryStore) Create

func (*MemoryStore) Delete

func (m *MemoryStore) Delete(_ context.Context, id string) error

func (*MemoryStore) FindGitHubGrantingOrgs

func (m *MemoryStore) FindGitHubGrantingOrgs(_ context.Context, keys []string) ([]OrgSSOProvider, error)

func (*MemoryStore) Get

func (*MemoryStore) GitHubGatingActive

func (m *MemoryStore) GitHubGatingActive(_ context.Context) (bool, error)

func (*MemoryStore) ListByTenant

func (m *MemoryStore) ListByTenant(_ context.Context, tenantID string) ([]OrgSSOProvider, error)

func (*MemoryStore) ListByTenantKind

func (m *MemoryStore) ListByTenantKind(_ context.Context, tenantID string, kind Kind) ([]OrgSSOProvider, error)

func (*MemoryStore) Update

type MongoDomainStore

type MongoDomainStore struct {
	// contains filtered or unexported fields
}

MongoDomainStore is the production DomainStore.

func NewMongoDomainStore

func NewMongoDomainStore(db *mongo.Database) *MongoDomainStore

NewMongoDomainStore wires a MongoDomainStore on the given database.

func (*MongoDomainStore) Create

func (*MongoDomainStore) Delete

func (s *MongoDomainStore) Delete(ctx context.Context, id string) error

func (*MongoDomainStore) EnsureSchema

func (s *MongoDomainStore) EnsureSchema(ctx context.Context) error

EnsureSchema creates the unique (tenant_id, domain) index.

func (*MongoDomainStore) Get

func (*MongoDomainStore) IsVerifiedForTenant

func (s *MongoDomainStore) IsVerifiedForTenant(ctx context.Context, tenantID, domain string) (bool, error)

func (*MongoDomainStore) ListByTenant

func (s *MongoDomainStore) ListByTenant(ctx context.Context, tenantID string) ([]VerifiedDomain, error)

func (*MongoDomainStore) TenantsForDomain

func (s *MongoDomainStore) TenantsForDomain(ctx context.Context, domain string) ([]string, error)

func (*MongoDomainStore) Update

type MongoStore

type MongoStore struct {
	// contains filtered or unexported fields
}

MongoStore is the production Store.

func NewMongoStore

func NewMongoStore(db *mongo.Database) *MongoStore

NewMongoStore wires a MongoStore on the given database.

func (*MongoStore) Create

func (s *MongoStore) Create(ctx context.Context, p OrgSSOProvider) error

func (*MongoStore) Delete

func (s *MongoStore) Delete(ctx context.Context, id string) error

func (*MongoStore) EnsureSchema

func (s *MongoStore) EnsureSchema(ctx context.Context) error

EnsureSchema creates the indexes:

  • (tenant_id, kind): the general per-tenant listing index.
  • partial-unique (tenant_id) where kind="github": at most one GitHub gating row per org (a single allow-list; the Grants array composes multiple GitHub orgs/teams inside it). A distinct key pattern from the listing index so the two don't collide.
  • partial multikey (github_team_keys) where kind="github" & enabled: the reverse-lookup index that turns a GitHub login into one $in query.

func (*MongoStore) FindGitHubGrantingOrgs

func (s *MongoStore) FindGitHubGrantingOrgs(ctx context.Context, keys []string) ([]OrgSSOProvider, error)

func (*MongoStore) Get

func (s *MongoStore) Get(ctx context.Context, id string) (OrgSSOProvider, error)

func (*MongoStore) GitHubGatingActive

func (s *MongoStore) GitHubGatingActive(ctx context.Context) (bool, error)

func (*MongoStore) ListByTenant

func (s *MongoStore) ListByTenant(ctx context.Context, tenantID string) ([]OrgSSOProvider, error)

func (*MongoStore) ListByTenantKind

func (s *MongoStore) ListByTenantKind(ctx context.Context, tenantID string, kind Kind) ([]OrgSSOProvider, error)

func (*MongoStore) Update

func (s *MongoStore) Update(ctx context.Context, p OrgSSOProvider) error

type OrgSSOProvider

type OrgSSOProvider struct {
	ID          string `bson:"_id" json:"id"`
	TenantID    string `bson:"tenant_id" json:"tenant_id"`
	Kind        Kind   `bson:"kind" json:"kind"`
	Enabled     bool   `bson:"enabled" json:"enabled"`
	DisplayName string `bson:"display_name,omitempty" json:"display_name,omitempty"`

	// IssuerURL is the OIDC issuer (no trailing slash); discovery hits
	// <IssuerURL>/.well-known/openid-configuration. Stored in the clear.
	IssuerURL string `bson:"issuer_url,omitempty" json:"issuer_url,omitempty"`
	// ClientID is stored in the clear (the admin UI lists it). SealedSecret
	// holds the client_secret sealed via secrets.Sealer with AAD
	// "org_sso_provider:<ID>" — never serialised out of the server.
	ClientID     string   `bson:"client_id,omitempty" json:"client_id,omitempty"`
	SealedSecret []byte   `bson:"sealed_secret,omitempty" json:"-"`
	Scopes       []string `bson:"scopes,omitempty" json:"scopes,omitempty"`
	// DefaultRole is the membership role granted to a user who logs in via
	// this OIDC provider for this org. Defaults to member; never owner.
	DefaultRole identity.Role `bson:"default_role,omitempty" json:"default_role,omitempty"`
	// AutoLinkOnEmail is a Phase-3 opt-in (default off): auto-link a fresh
	// OIDC identity onto an existing iterion user matched by verified email.
	// Stored now so the shape is stable; NOT acted upon until JWKS ID-token
	// verification lands (the safe-auto-link prerequisite).
	AutoLinkOnEmail bool `bson:"auto_link_on_email,omitempty" json:"auto_link_on_email,omitempty"`

	// Grants maps (GitHub org, team) → iterion role. Evaluated at GitHub
	// login: a user whose GitHub teams intersect Grants is granted membership
	// in this org (Phase 2). Ordered: the first matching grant wins.
	Grants []GitHubTeamGrant `bson:"grants,omitempty" json:"grants,omitempty"`
	// GitHubTeamKeys is the materialised reverse-lookup view of Grants
	// ("<org>/<team_slug>" + "<org>/*", lowercased), maintained on write so a
	// GitHub login resolves matching orgs with one $in query instead of a
	// cross-tenant scan. Internal — never serialised.
	GitHubTeamKeys []string `bson:"github_team_keys,omitempty" json:"-"`
	// AutoProvision: when true a matching user is auto-added to this org; when
	// false, login is allowed only if a membership already exists (the admin
	// must invite first).
	AutoProvision bool `bson:"auto_provision,omitempty" json:"auto_provision"`

	CreatedBy string    `bson:"created_by" json:"created_by"`
	CreatedAt time.Time `bson:"created_at" json:"created_at"`
	UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}

OrgSSOProvider is one per-tenant SSO configuration row.

func (*OrgSSOProvider) Normalize

func (p *OrgSSOProvider) Normalize()

Normalize canonicalises a row in place before persistence: trims the issuer URL, defaults scopes/role, and (re)materialises GitHubTeamKeys.

func (OrgSSOProvider) OIDCSlug

func (p OrgSSOProvider) OIDCSlug() string

OIDCSlug returns the URL slug used for this provider's /api/auth/oidc/<slug>/… routes. Only meaningful for KindOIDC rows.

func (OrgSSOProvider) RoleForGroups

func (p OrgSSOProvider) RoleForGroups(groups []string) (identity.Role, bool)

RoleForGroups returns the role this GitHub row grants a user holding the given (lowercased) group keys — the first matching grant in declaration order, clamped to at most member. ok is false when no grant matches.

func (*OrgSSOProvider) Validate

func (p *OrgSSOProvider) Validate() error

Validate enforces the kind-specific contract + the static SSO security invariants (https issuer, no owner grant, valid roles). Call after Normalize.

type Store

type Store interface {
	Create(ctx context.Context, p OrgSSOProvider) error
	Get(ctx context.Context, id string) (OrgSSOProvider, error)
	Update(ctx context.Context, p OrgSSOProvider) error
	Delete(ctx context.Context, id string) error

	// ListByTenant returns every provider for a tenant, oldest first.
	ListByTenant(ctx context.Context, tenantID string) ([]OrgSSOProvider, error)
	// ListByTenantKind narrows ListByTenant to one Kind (e.g. enabled OIDC
	// rows for the org-scoped login picker).
	ListByTenantKind(ctx context.Context, tenantID string, kind Kind) ([]OrgSSOProvider, error)

	// FindGitHubGrantingOrgs is the load-bearing reverse lookup for GitHub
	// team-gating: every ENABLED KindGitHub row whose GitHubTeamKeys intersect
	// keys. Backed by a multikey $in index — never a cross-tenant scan.
	FindGitHubGrantingOrgs(ctx context.Context, keys []string) ([]OrgSSOProvider, error)

	// GitHubGatingActive reports whether any enabled KindGitHub row exists at
	// all. When true, a GitHub login that matches no allow-listed team and has
	// no prior access is refused (ErrSSORestricted); when false, GitHub login
	// behaves as before (no team-gating in this deployment).
	GitHubGatingActive(ctx context.Context) (bool, error)
}

Store persists per-tenant SSO provider rows. Get is keyed by id only — the HTTP layer asserts tenant ownership (row.TenantID == teamID) before mutating, matching the forge OAuthAppStore convention.

type TXTLookupFunc

type TXTLookupFunc func(ctx context.Context, name string) ([]string, error)

TXTLookupFunc resolves the TXT records for a name. Injectable for tests; the production default wraps net.Resolver.LookupTXT.

func DefaultTXTLookup

func DefaultTXTLookup() TXTLookupFunc

DefaultTXTLookup returns the production net-backed TXT lookup.

type VerifiedDomain

type VerifiedDomain struct {
	ID         string     `bson:"_id" json:"id"`
	TenantID   string     `bson:"tenant_id" json:"tenant_id"`
	Domain     string     `bson:"domain" json:"domain"` // lowercased, no leading "@"
	Token      string     `bson:"token" json:"token"`   // the TXT challenge value
	VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
	CreatedBy  string     `bson:"created_by,omitempty" json:"created_by,omitempty"`
	CreatedAt  time.Time  `bson:"created_at" json:"created_at"`
}

VerifiedDomain records a tenant's claim over an email domain, proven by a DNS TXT challenge. It gates per-org SSO auto-link: an org's Keycloak may only auto-link a fresh OIDC identity onto an existing iterion user by email when the email's domain is verified for that org — so a malicious org IdP can't claim email_verified for an address outside the org's domain authority and take over an unrelated account (the H-16 account-takeover gate; JWKS alone is insufficient because the org controls its own IdP signing keys).

func (VerifiedDomain) ChallengeHost

func (d VerifiedDomain) ChallengeHost() string

ChallengeHost is the DNS name the admin must create a TXT record at.

func (VerifiedDomain) ChallengeValue

func (d VerifiedDomain) ChallengeValue() string

ChallengeValue is the exact TXT record value to publish.

func (VerifiedDomain) Verified

func (d VerifiedDomain) Verified() bool

Verified reports whether the domain claim has been DNS-verified.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL