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 }