feat(org): org-level email domain policy for members (#727) #732

Merged
jmiller merged 1 commits from feat/org-email-domain into dev 2026-07-05 04:34:26 +00:00
9 changed files with 257 additions and 0 deletions
+1
View File
@@ -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)
+134
View File
@@ -0,0 +1,134 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}
+1
View File
@@ -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
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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))
}
+15
View File
@@ -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"`
}
+6
View File
@@ -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() {
+67
View File
@@ -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)
}
+3
View File
@@ -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)
}
+7
View File
@@ -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
}