diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go index 5344e660a0..d4eeb7a9f7 100644 --- a/models/licenses/license_key.go +++ b/models/licenses/license_key.go @@ -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" ) diff --git a/models/licenses/license_package.go b/models/licenses/license_package.go index 06bb20bd32..8672479d52 100644 --- a/models/licenses/license_package.go +++ b/models/licenses/license_package.go @@ -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) diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 8a2b19682d..f50876b3a7 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_27/v341.go b/models/migrations/v1_27/v341.go new file mode 100644 index 0000000000..5beaae0199 --- /dev/null +++ b/models/migrations/v1_27/v341.go @@ -0,0 +1,21 @@ +// Copyright 2026 Moko Consulting +// 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)) +} diff --git a/models/organization/org.go b/models/organization/org.go index 739dfc6e46..ce174e1656 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -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) diff --git a/models/user/user.go b/models/user/user.go index 8a1a0e43d1..5319d11b24 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -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 ''"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 1e840e6c90..323dc721c1 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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", diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index b36cbf0655..80e2525f9a 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -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") diff --git a/templates/org/settings/options.tmpl b/templates/org/settings/options.tmpl index 5bd9bda4e4..00b9ae7360 100644 --- a/templates/org/settings/options.tmpl +++ b/templates/org/settings/options.tmpl @@ -28,6 +28,19 @@ + {{if .ParentOrgCandidates}} +
+ + +

{{ctx.Locale.Tr "org.settings.parent_org_help"}}

+
+ {{end}} +