From 6a3db171c1f7608fcafe994b597fe455fc56d4ee Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 4 Jul 2026 22:40:39 -0500 Subject: [PATCH] feat(org): org-level email domain policy for members (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restricts 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_01Wsno14cxE49MstXFs9G5KT --- CHANGELOG.md | 1 + models/git/org_email_domain.go | 134 ++++++++++++++++++++++++++++ models/migrations/migrations.go | 1 + models/migrations/v1_27/v367.go | 23 +++++ modules/structs/org_email_domain.go | 15 ++++ routers/api/v1/api.go | 6 ++ routers/api/v1/org/email_domain.go | 67 ++++++++++++++ routers/api/v1/org/team.go | 3 + services/org/team.go | 7 ++ 9 files changed, 257 insertions(+) create mode 100644 models/git/org_email_domain.go create mode 100644 models/migrations/v1_27/v367.go create mode 100644 modules/structs/org_email_domain.go create mode 100644 routers/api/v1/org/email_domain.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a65be7877..3ece384d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Org-level tag protection: protect tag patterns org-wide (e.g. `v*`) with a team allowlist, layered on top of each repo's own protected tags — a tag is controllable only if allowed at both levels (fail-closed). API at `/orgs/{org}/tag_protections`; enforced at the git push/delete hook and the release create/delete paths; shown read-only in the repo Tag settings (#727) - Org-level push policy: one policy per org, enforced in the pre-receive hook across all its repositories — branch/tag name conventions (glob), a mandatory secret-scanning block-on-push that repos cannot disable, a max pushed-file size, and blocked file-path patterns. API at `/orgs/{org}/push_policy`. Naming is fail-closed; the content checks (blocked paths, max size) fail open on error so a policy bug can never block every push (#727) - Org-level repository defaults: an org can force new/transferred repositories private and set default pull-request settings (allowed merge styles, default merge style, auto-delete branch after merge), applied via a notifier when a repo is created in or transferred into the org (best-effort — never blocks repo creation). API at `/orgs/{org}/repo_defaults` (#727) +- Org-level email domain policy: restrict which email domains an organization's members may have — 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-add choke point (`AddTeamMember`); API at `/orgs/{org}/email_domain_policy` (#727) - Code security scanner: pattern-based detection of SQL injection, XSS, command injection, path traversal, insecure deserialization, hardcoded credentials, and weak cryptography across Go/PHP/Python/JS/TS (#552) - Cascade merge: auto-create PRs to downstream branches after merge with configurable rules per repo (#460) - Issue status presets: 4 built-in templates (default, software-development, support-tickets, bug-tracking) with API + web UI (#507) diff --git a/models/git/org_email_domain.go b/models/git/org_email_domain.go new file mode 100644 index 0000000000..9984d5d60b --- /dev/null +++ b/models/git/org_email_domain.go @@ -0,0 +1,134 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package git + +import ( + "context" + "fmt" + "strings" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/glob" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" + + "xorm.io/builder" +) + +// OrgEmailDomainPolicy restricts which email domains an organization's members may +// have. When configured, a user can only be added to the org if their primary email +// matches one of the allowed domain globs. At most one row per org. See issue #727. +type OrgEmailDomainPolicy struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"UNIQUE NOT NULL"` + AllowedDomains string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func init() { + db.RegisterModel(new(OrgEmailDomainPolicy)) +} + +// ErrEmailDomainNotAllowed is returned when a user's email domain is not permitted +// by the organization's email domain policy. +type ErrEmailDomainNotAllowed struct { + Email string + OrgID int64 +} + +func (e ErrEmailDomainNotAllowed) Error() string { + return fmt.Sprintf("email %q is not in an allowed domain for organization %d", e.Email, e.OrgID) +} + +// IsErrEmailDomainNotAllowed reports whether err is an ErrEmailDomainNotAllowed. +func IsErrEmailDomainNotAllowed(err error) bool { + _, ok := err.(ErrEmailDomainNotAllowed) + return ok +} + +func (p *OrgEmailDomainPolicy) domainGlobs() []glob.Glob { + var out []glob.Glob + for _, d := range strings.Split(p.AllowedDomains, ";") { + d = strings.TrimSpace(strings.ToLower(d)) + if d == "" { + continue + } + if g, err := glob.Compile(d); err == nil { + out = append(out, g) + } else { + log.Warn("Invalid org email domain glob %q: %v", d, err) + } + } + return out +} + +// EmailAllowed reports whether email's domain satisfies the policy. An empty policy +// (no configured domains) allows any email. +func (p *OrgEmailDomainPolicy) EmailAllowed(email string) bool { + globs := p.domainGlobs() + if len(globs) == 0 { + return true + } + at := strings.LastIndexByte(email, '@') + if at < 0 { + return false + } + domain := strings.ToLower(email[at+1:]) + for _, g := range globs { + if g.Match(domain) { + return true + } + } + return false +} + +// GetOrgEmailDomainPolicy returns the org's email domain policy, or nil if none. +func GetOrgEmailDomainPolicy(ctx context.Context, orgID int64) (*OrgEmailDomainPolicy, error) { + policy, exist, err := db.Get[OrgEmailDomainPolicy](ctx, builder.Eq{"org_id": orgID}) + if err != nil { + return nil, err + } else if !exist { + return nil, nil //nolint:nilnil + } + return policy, nil +} + +// OrgEmailDomainAllowed reports whether email is permitted for the org. It returns +// true when the org has no policy configured. +func OrgEmailDomainAllowed(ctx context.Context, orgID int64, email string) (bool, error) { + policy, err := GetOrgEmailDomainPolicy(ctx, orgID) + if err != nil { + return false, err + } + if policy == nil { + return true, nil + } + return policy.EmailAllowed(email), nil +} + +// UpsertOrgEmailDomainPolicy creates or updates the single policy row for an org. +func UpsertOrgEmailDomainPolicy(ctx context.Context, policy *OrgEmailDomainPolicy) error { + existing, err := GetOrgEmailDomainPolicy(ctx, policy.OrgID) + if err != nil { + return err + } + if existing == nil { + if _, err := db.GetEngine(ctx).Insert(policy); err != nil { + return fmt.Errorf("Insert OrgEmailDomainPolicy: %v", err) + } + return nil + } + policy.ID = existing.ID + if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(policy); err != nil { + return fmt.Errorf("Update OrgEmailDomainPolicy: %v", err) + } + return nil +} + +// DeleteOrgEmailDomainPolicy removes an org's email domain policy. +func DeleteOrgEmailDomainPolicy(ctx context.Context, orgID int64) error { + _, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgEmailDomainPolicy)) + return err +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 711923b24a..46e6e657ba 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -443,6 +443,7 @@ func prepareMigrationTasks() []*migration { newMigration(363, "Add org protected tag table", v1_27.AddOrgProtectedTagTable), newMigration(364, "Add org push policy table", v1_27.AddOrgPushPolicyTable), newMigration(365, "Add org repo defaults table", v1_27.AddOrgRepoDefaultsTable), + newMigration(366, "Add org email domain policy table", v1_27.AddOrgEmailDomainPolicyTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v367.go b/models/migrations/v1_27/v367.go new file mode 100644 index 0000000000..bf7612e21d --- /dev/null +++ b/models/migrations/v1_27/v367.go @@ -0,0 +1,23 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" + + "xorm.io/xorm" +) + +// AddOrgEmailDomainPolicyTable creates the org email-domain policy table (one row +// per org) restricting the email domains of members added to the org. See #727. +func AddOrgEmailDomainPolicyTable(x *xorm.Engine) error { + type OrgEmailDomainPolicy struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"UNIQUE NOT NULL"` + AllowedDomains string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + return x.Sync(new(OrgEmailDomainPolicy)) +} diff --git a/modules/structs/org_email_domain.go b/modules/structs/org_email_domain.go new file mode 100644 index 0000000000..fc80f9f1ca --- /dev/null +++ b/modules/structs/org_email_domain.go @@ -0,0 +1,15 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// OrgEmailDomainPolicy represents an organization's email domain policy +type OrgEmailDomainPolicy struct { + OrgID int64 `json:"org_id"` + AllowedDomains string `json:"allowed_domains"` +} + +// EditOrgEmailDomainPolicyOption options for editing an org's email domain policy +type EditOrgEmailDomainPolicyOption struct { + AllowedDomains *string `json:"allowed_domains"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 40b5441447..a39d93e934 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1845,6 +1845,12 @@ func Routes() *web.Router { Delete(org.DeleteOrgRepoDefaults) }, reqToken(), reqOrgOwnership()) + m.Group("/email_domain_policy", func() { + m.Combo("").Get(org.GetOrgEmailDomainPolicy). + Patch(bind(api.EditOrgEmailDomainPolicyOption{}), org.EditOrgEmailDomainPolicy). + Delete(org.DeleteOrgEmailDomainPolicy) + }, reqToken(), reqOrgOwnership()) + m.Group("/blocks", func() { m.Get("", org.ListBlocks) m.Group("/{username}", func() { diff --git a/routers/api/v1/org/email_domain.go b/routers/api/v1/org/email_domain.go new file mode 100644 index 0000000000..ade4f8bf05 --- /dev/null +++ b/routers/api/v1/org/email_domain.go @@ -0,0 +1,67 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git" + api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" +) + +func toAPIOrgEmailDomainPolicy(policy *git_model.OrgEmailDomainPolicy, orgID int64) *api.OrgEmailDomainPolicy { + if policy == nil { + return &api.OrgEmailDomainPolicy{OrgID: orgID} + } + return &api.OrgEmailDomainPolicy{ + OrgID: policy.OrgID, + AllowedDomains: policy.AllowedDomains, + } +} + +// GetOrgEmailDomainPolicy get the organization's email domain policy +func GetOrgEmailDomainPolicy(ctx *context.APIContext) { + orgID := ctx.Org.Organization.ID + policy, err := git_model.GetOrgEmailDomainPolicy(ctx, orgID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, toAPIOrgEmailDomainPolicy(policy, orgID)) +} + +// EditOrgEmailDomainPolicy create or update the organization's email domain policy +func EditOrgEmailDomainPolicy(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.EditOrgEmailDomainPolicyOption) + orgID := ctx.Org.Organization.ID + + policy, err := git_model.GetOrgEmailDomainPolicy(ctx, orgID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if policy == nil { + policy = &git_model.OrgEmailDomainPolicy{OrgID: orgID} + } + if form.AllowedDomains != nil { + policy.AllowedDomains = *form.AllowedDomains + } + + if err := git_model.UpsertOrgEmailDomainPolicy(ctx, policy); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, toAPIOrgEmailDomainPolicy(policy, orgID)) +} + +// DeleteOrgEmailDomainPolicy remove the organization's email domain policy +func DeleteOrgEmailDomainPolicy(ctx *context.APIContext) { + if err := git_model.DeleteOrgEmailDomainPolicy(ctx, ctx.Org.Organization.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index d93aab1c5a..07583d5f6f 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -9,6 +9,7 @@ import ( "net/http" activities_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/activities" + git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm" access_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access" @@ -491,6 +492,8 @@ func AddTeamMember(ctx *context.APIContext) { if err := org_service.AddTeamMember(ctx, ctx.Org.Team, u); err != nil { if errors.Is(err, user_model.ErrBlockedUser) { ctx.APIError(http.StatusForbidden, err) + } else if git_model.IsErrEmailDomainNotAllowed(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) } else { ctx.APIErrorInternal(err) } diff --git a/services/org/team.go b/services/org/team.go index 26f9507c8e..ccf6c8b0a9 100644 --- a/services/org/team.go +++ b/services/org/team.go @@ -220,6 +220,13 @@ func AddTeamMember(ctx context.Context, team *organization.Team, user *user_mode return err } + // Enforce the organization email domain policy for new members. + if allowed, err := git_model.OrgEmailDomainAllowed(ctx, team.OrgID, user.Email); err != nil { + return err + } else if !allowed { + return git_model.ErrEmailDomainNotAllowed{Email: user.Email, OrgID: team.OrgID} + } + if err := organization.AddOrgUser(ctx, team.OrgID, user.ID); err != nil { return err } -- 2.52.0