feat(org): org-level email domain policy for members (#727) #732
Reference in New Issue
Block a user
Delete Branch "feat/org-email-domain"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of the org-governance series (#728–#731). Adds an org email domain policy — the one bounded piece of the "access/security" tier.
What it does
When an org configures allowed email domains, a user can only be added to the org (via any team) if their primary email matches one of the allowed domain globs (e.g.
example.com;*.example.org). An empty policy imposes no restriction.How — one choke point
Every membership-add path (API, web UI, external group-sync) funnels through
services/org.AddTeamMember, so a single check there covers them all. On violation it returns a typedgit_model.ErrEmailDomainNotAllowed; the API team-add handler maps it to 422 (web surfaces the message).models/git/org_email_domain.go:OrgEmailDomainPolicy+EmailAllowed(domain glob match) +OrgEmailDomainAllowed+ typed error + CRUD. Migration 366.GET/PATCH/DELETE /orgs/{org}/email_domain_policy.AddTeamMember; 422 mapping inrouters/api/v1/org/team.go.Scope note — the rest of Tier 4 was deliberately NOT built
I built only this piece. Org 2FA-required and org IP allowlists were left out on purpose: they're cross-cutting enforcement (auth gating on many actions; request middleware on every request) with no single choke point — a blind, uncompiled bug there means "nobody can log in," not "a policy doesn't apply." They deserve compiler-backed, tested work. And org-wide required status checks — the fourth Tier-4 item — is already delivered by org branch protection (#728,
StatusCheckContexts).Caveats
user.Email(the primary), not secondary emails. Reasonable default; noted for review.gofmt'd/tested. Hand-verified: gofmt, gci import order (git_modeladded to the api team handler), typed-error detection, migration contiguous. Integration check: set an org policy and try adding a user whose email is outside the allowed domains — expect 422 / rejection.https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KT
LandingPageType.Mode defaults to "" (Go zero value), and the template renders the home radio as checked for an empty Mode. The initial radio fill would evaluate home.checked = ("home" === "") = false, unchecking the default on a fresh install. Skip assignment when the config value is empty so the server-rendered selection is preserved. Adds a test for the empty-value case.Adds org-level tag protection as a parallel to org-level branch protection. An org tag rule is {NamePattern, AllowlistTeamIDs}; it cascades to every repo in the org and layers on top of the repo's own protected tags — a tag is controllable (push/delete) only if allowed at BOTH levels (fail-closed). - models/git/org_protected_tag.go: OrgProtectedTag model + CRUD + ToProtectedTag() (reuses the ProtectedTag matcher/allowlist logic) + IsUserAllowedToControlTagInRepo() which ANDs the repo decision with the org decision. Migration 363. - API: /orgs/{org}/tag_protections CRUD (routers/api/v1/org/tag_protection.go, DTOs in modules/structs/org_tag.go, wired in api.go). - Enforcement: the git push/delete hook (hook_pre_receive.go) and the two release paths (release.go create/delete) now call the layered check, so no per-site tag logic changes beyond swapping the helper. - View: the repo Tag settings page lists inherited org tag rules read-only. Stacked on #728 (branch-protection PR) for migration ordering — merge #728 first. Swagger annotations omitted (can't regenerate the swagger JSON without the toolchain); routes still register. Note: no Go toolchain available locally, so not compiled/gofmt'd/tested here. Hand-verified: gofmt (tabs, no blank-in-block, struct alignment), template nesting balances, all .Rule fields exist on OrgProtectedTag, all locale keys defined, JSON valid, migration contiguous (363). Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KTAdds a single per-org push policy that cascades to every repo of the org and is enforced in the pre-receive hook: - Branch/tag name conventions (glob) — a pushed ref name must match. Fail-closed. - Mandatory secret-scanning block-on-push — org can force secret blocking that a repo cannot disable (overrides the per-repo scanner config in the orchestrator). - Max pushed-file size — rejects a tip tree containing a blob over the limit. - Blocked file-path patterns — rejects pushes changing matching paths (reuses pull_service.CheckFileProtection). The two content checks (blocked paths, max size) FAIL OPEN on any error so a policy/parsing bug can never wedge all pushes; naming is fail-closed. - models/git/org_push_policy.go: OrgPushPolicy model + CRUD + matchers + GetOrgPushPolicyForRepo. Migration 364. - API: GET/PATCH/DELETE /orgs/{org}/push_policy (routers/api/v1/org/push_policy.go, DTOs in modules/structs/org_push_policy.go, wired in api.go). - Enforcement: routers/private/hook_pre_receive.go (branch: naming + blocked paths + max size; tag: naming) and services/security/orchestrator.go (secret mandate). Deferred: a repo-facing read-only view of the org push policy (it is an org-wide config, not per-repo overlay rules; readable via the API for now). Stacked on #729/#728 for migration ordering (this = 364). Swagger annotations omitted (can't regenerate without the toolchain). Note: no Go toolchain available locally, so not compiled/gofmt'd/tested here. Hand-verified: gofmt (tabs, no blank-in-block), escape sequences in the ls-tree parser, imports used, migration contiguous (364), fail-open on content checks. Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KTAdds a single per-org repository-defaults config, applied to a repo when it is created in or transferred into the org via a notifier (services/org): - ForcePrivate — force new/transferred repos private (Repository.IsPrivate). - PR defaults (when ApplyPRDefaults) — allowed merge styles, default merge style, and auto-delete-branch-after-merge, written to the repo's pull-requests unit config via repo_service.UpdateRepositoryUnits. Best-effort: the notifier logs and swallows errors, so a defaults bug can never break repository creation or transfer. - models/git/org_repo_defaults.go: OrgRepoDefaults model + CRUD + migration 365. - API: GET/PATCH/DELETE /orgs/{org}/repo_defaults. - services/org/notifier.go: CreateRepository/TransferRepository -> apply defaults; registered from routers/init.go (org_service.Init()). Stacked on #730/#729/#728 for migration ordering (this = 365). Swagger omitted. Note: no Go toolchain available locally, so not compiled/gofmt'd/tested here. Hand-verified: gofmt (tabs, no blank-in-block, struct/DTO alignment), imports used, no Init() collision in services/org, migration contiguous (365), notifier signatures match the Notifier interface. Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KTRestricts which email domains an organization's members may have. When a policy is configured, a user can only be added to the org (via any team) if their primary email matches one of the allowed domain globs. Enforced at the single membership choke point services/org.AddTeamMember, which every add path (API, web, group-sync) funnels through — so one check covers them all. On violation it returns a typed ErrEmailDomainNotAllowed; the API team-add handler maps it to 422. - models/git/org_email_domain.go: OrgEmailDomainPolicy model + EmailAllowed (domain glob match) + OrgEmailDomainAllowed + typed error + CRUD. Migration 366. - API: GET/PATCH/DELETE /orgs/{org}/email_domain_policy. - Enforcement in services/org/team.go; 422 mapping in routers/api/v1/org/team.go. An empty policy imposes no restriction. This is the one bounded piece of the "access/security" tier; org 2FA-required and IP allowlists were deliberately NOT built here — they are cross-cutting enforcement (auth gating / request middleware) that needs a compiler + tests, not a blind stacked PR. Stacked on #731/#730/#729/#728 for migration ordering (this = 366). Swagger omitted. Note: no Go toolchain available locally, so not compiled/gofmt'd/tested here. Hand-verified: gofmt (tabs, no blank-in-block), imports (git_model added to the api team handler, gci order), typed-error detection, migration contiguous (366). Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KTf32fe74b12to6a3db171c1