feat(org): org-level email domain policy for members (#727) #732
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user