feat(orgs): enterprise sub-org hierarchy (#410) #411

Merged
jmiller merged 1 commits from dev into main 2026-06-02 13:15:38 +00:00
9 changed files with 138 additions and 0 deletions
+1
View File
@@ -13,6 +13,7 @@ import (
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
+28
View File
@@ -5,8 +5,10 @@ package licenses
import (
"context"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/builder"
@@ -79,6 +81,32 @@ func ListLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage,
return pkgs, db.GetEngine(ctx).Where("owner_id = ? AND is_archived = ?", ownerID, false).Find(&pkgs)
}
// ListLicensePackagesWithAncestors returns packages from the org and all parent orgs.
func ListLicensePackagesWithAncestors(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
ancestorIDs := org_model.GetAncestorOrgIDs(ctx, ownerID)
pkgs := make([]*LicensePackage, 0, 10)
return pkgs, db.GetEngine(ctx).In("owner_id", ancestorIDs).Where("is_archived = ?", false).Find(&pkgs)
}
// ListLicenseKeysWithAncestors returns keys from the org and all parent orgs.
func ListLicenseKeysWithAncestors(ctx context.Context, ownerID int64) ([]*LicenseKey, error) {
ancestorIDs := org_model.GetAncestorOrgIDs(ctx, ownerID)
keys := make([]*LicenseKey, 0, 20)
return keys, db.GetEngine(ctx).In("owner_id", ancestorIDs).Find(&keys)
}
// SearchLicenseKeysWithAncestors searches keys across the org and all parent orgs.
func SearchLicenseKeysWithAncestors(ctx context.Context, ownerID int64, query string) ([]*LicenseKey, error) {
ancestorIDs := org_model.GetAncestorOrgIDs(ctx, ownerID)
keys := make([]*LicenseKey, 0, 20)
like := "%" + strings.ToLower(query) + "%"
return keys, db.GetEngine(ctx).
In("owner_id", ancestorIDs).
And("(LOWER(key_prefix) LIKE ? OR LOWER(key_raw) LIKE ? OR LOWER(licensee_name) LIKE ? OR LOWER(licensee_email) LIKE ? OR LOWER(domain_restriction) LIKE ? OR LOWER(payment_ref) LIKE ?)",
like, like, like, like, like, like).
Find(&keys)
}
// ListArchivedLicensePackages returns archived packages for the given owner.
func ListArchivedLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
pkgs := make([]*LicensePackage, 0, 10)
+1
View File
@@ -418,6 +418,7 @@ func prepareMigrationTasks() []*migration {
newMigration(338, "Add platform and require_key to update_stream_config", v1_27.AddPlatformAndRequireKeyToStreamConfig),
newMigration(339, "Add AI assistant tables", v1_27.AddAITables),
newMigration(340, "Sync license system columns (key_raw, payment_ref, heartbeat, archive, metadata)", v1_27.SyncLicenseSystemColumns),
newMigration(341, "Add parent_org_id to user table for enterprise sub-org hierarchy", v1_27.AddParentOrgIDToUser),
}
return preparedMigrations
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import "xorm.io/xorm"
type userParentOrg341 struct {
ID int64 `xorm:"pk autoincr"`
ParentOrgID int64 `xorm:"INDEX DEFAULT 0"`
}
func (userParentOrg341) TableName() string {
return "user"
}
// AddParentOrgIDToUser adds the parent_org_id column to the user table
// for enterprise sub-org hierarchy support.
func AddParentOrgIDToUser(x *xorm.Engine) error {
return x.Sync(new(userParentOrg341))
}
+42
View File
@@ -97,6 +97,48 @@ func (org *Organization) IsOrgAdmin(ctx context.Context, uid int64) (bool, error
return IsOrganizationAdmin(ctx, org.ID, uid)
}
// HasParentOrg returns true if this org has a parent.
func (org *Organization) HasParentOrg() bool {
return org.ParentOrgID > 0
}
// GetParentOrg returns the parent organization, or nil if none.
func (org *Organization) GetParentOrg(ctx context.Context) (*Organization, error) {
if org.ParentOrgID == 0 {
return nil, nil
}
return GetOrgByID(ctx, org.ParentOrgID)
}
// GetChildOrgs returns all direct child organizations.
func GetChildOrgs(ctx context.Context, parentOrgID int64) ([]*Organization, error) {
var orgs []*Organization
return orgs, db.GetEngine(ctx).
Where("type = ? AND parent_org_id = ?", user_model.UserTypeOrganization, parentOrgID).
Find(&orgs)
}
// GetAncestorOrgIDs returns all org IDs in the parent chain (including self).
// Used for license validation — a key from any ancestor org is valid.
func GetAncestorOrgIDs(ctx context.Context, orgID int64) []int64 {
ids := []int64{orgID}
currentID := orgID
seen := map[int64]bool{orgID: true}
for i := 0; i < 10; i++ { // max 10 levels to prevent infinite loops
org, err := GetOrgByID(ctx, currentID)
if err != nil || org.ParentOrgID == 0 {
break
}
if seen[org.ParentOrgID] {
break // cycle detected
}
seen[org.ParentOrgID] = true
ids = append(ids, org.ParentOrgID)
currentID = org.ParentOrgID
}
return ids
}
// IsOrgMember returns true if given user is member of organization.
func (org *Organization) IsOrgMember(ctx context.Context, uid int64) (bool, error) {
return IsOrganizationMember(ctx, org.ID, uid)
+1
View File
@@ -152,6 +152,7 @@ type User struct {
NumMembers int
Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 0"`
RepoAdminChangeTeamAccess bool `xorm:"NOT NULL DEFAULT false"`
ParentOrgID int64 `xorm:"INDEX DEFAULT 0"` // 0 = no parent (top-level org)
// Preferences
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
+3
View File
@@ -2908,6 +2908,9 @@
"org.settings.streams_tag_help": "When licensing is active, release tags with prerelease suffixes must match one of the streams above (e.g. v1.0.0-rc1 matches the -rc stream).",
"org.settings.custom_streams": "Custom Stream Definitions (JSON)",
"org.settings.custom_streams_help": "JSON array of stream objects. Each needs: name, suffix, description. Example: [{\"name\":\"lts\",\"suffix\":\"-lts\",\"description\":\"Long-term support\"}]",
"org.settings.parent_org": "Parent Organization",
"org.settings.parent_org_none": "(none — top-level organization)",
"org.settings.parent_org_help": "Set a parent org for enterprise hierarchy. Child orgs inherit license packages and master keys from parent orgs.",
"org.settings.update_streams_saved": "Settings saved.",
"org.settings.full_name": "Full Name",
"org.settings.email": "Contact Email Address",
+28
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
packages_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/packages"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
@@ -47,6 +48,22 @@ func Settings(ctx *context.Context) {
ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility
ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess
ctx.Data["ContextUser"] = ctx.ContextUser
ctx.Data["ParentOrgID"] = ctx.Org.Organization.ParentOrgID
// Load available parent orgs (all orgs the current user owns, excluding self).
if ctx.Doer.IsAdmin || ctx.Org.IsOwner {
orgs, _ := org_model.FindOrgs(ctx, org_model.FindOrgOptions{
UserID: ctx.Doer.ID,
IncludePrivate: true,
})
var parentCandidates []*org_model.Organization
for _, o := range orgs {
if o.ID != ctx.Org.Organization.ID {
parentCandidates = append(parentCandidates, o)
}
}
ctx.Data["ParentOrgCandidates"] = parentCandidates
}
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
@@ -98,6 +115,17 @@ func SettingsPost(ctx *context.Context) {
return
}
// Save parent org (enterprise hierarchy).
parentOrgID := ctx.FormInt64("parent_org_id")
if parentOrgID != org.ParentOrgID {
user := org.AsUser()
user.ParentOrgID = parentOrgID
if err := user_model.UpdateUserCols(ctx, user, "parent_org_id"); err != nil {
ctx.ServerError("UpdateUserCols", err)
return
}
}
log.Trace("Organization setting updated: %s", org.Name)
ctx.Flash.Success(ctx.Tr("org.settings.update_setting_success"))
ctx.Redirect(ctx.Org.OrgLink + "/settings")
+13
View File
@@ -28,6 +28,19 @@
<input id="location" name="location" value="{{.Org.Location}}" maxlength="50">
</div>
{{if .ParentOrgCandidates}}
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.parent_org"}}</label>
<select name="parent_org_id" class="ui dropdown">
<option value="0">{{ctx.Locale.Tr "org.settings.parent_org_none"}}</option>
{{range .ParentOrgCandidates}}
<option value="{{.ID}}" {{if eq $.ParentOrgID .ID}}selected{{end}}>{{.Name}}{{if .FullName}} ({{.FullName}}){{end}}</option>
{{end}}
</select>
<p class="help">{{ctx.Locale.Tr "org.settings.parent_org_help"}}</p>
</div>
{{end}}
<div class="field" id="permission_box">
<label>{{ctx.Locale.Tr "org.settings.permission"}}</label>
<div class="field">