feat(orgs): enterprise sub-org hierarchy (#410) #411
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ''"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user