feat(org): add team visibility so org members can discover teams (#37680)

Closes #37670.

Today, org members in Gitea only see teams they're a member of. In
larger orgs that hurts onboarding and discoverability — there's no way
to look up which team owns what without asking around. GitHub solves
this with a per-team visibility setting; this PR brings the same model
to Gitea.

## What changes

- Every team gets a `visibility` setting:
- `private` *(default)* — only team members and org owners can see the
team. Same as today's behavior.
- `limited` — listable by any member of the organization. Members and
the repos the team has access to are visible too. Non-org-members still
see nothing.
  - `public` — listable by any signed-in user.
- The Owners team visibility is fixed and cannot be changed via
settings.
- Existing teams default to `private`, so this is a no-op for anyone who
doesn't change anything.

## API

- `Team`, `CreateTeamOption`, `EditTeamOption` all gain a `visibility`
field (string enum: `private` | `limited` | `public`).
- `GET /orgs/{org}/teams` and `/orgs/{org}/teams/search` now apply the
same visibility rules as the web UI:
  - site admins and org owners still see every team
- other org members see their own teams plus any `limited` or `public`
team
  - `private` teams are no longer leaked through these endpoints
- Swagger/OpenAPI specs regenerated.

## UI

View from admin2 (not an owner):
<img width="1669" height="726"
src="https://github.com/user-attachments/assets/daf4bccb-644b-4426-b178-71963aeaf73b"
/>

View from admin (owner):

<img width="2559" height="863"
src="https://github.com/user-attachments/assets/4f22cebc-e9df-4fd2-8ed4-724d31fadb7a"
/>

---------

Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
bircni
2026-06-14 21:07:25 +02:00
committed by GitHub
parent 80ca22a9ef
commit 55250407dd
31 changed files with 850 additions and 144 deletions

View File

@@ -57,8 +57,8 @@ func main() {
log.Fatalf("scanning swagger:enum annotations: %v", err) log.Fatalf("scanning swagger:enum annotations: %v", err)
} }
names := make([]string, 0, len(astEnumMap)) names := make([]string, 0, len(astEnumMap))
for _, n := range astEnumMap { for _, ns := range astEnumMap {
names = append(names, n) names = append(names, ns...)
} }
sort.Strings(names) sort.Strings(names)
fmt.Fprintf(os.Stderr, "discovered %d swagger:enum types: %s\n", len(names), strings.Join(names, ", ")) fmt.Fprintf(os.Stderr, "discovered %d swagger:enum types: %s\n", len(names), strings.Join(names, ", "))

View File

@@ -6,6 +6,7 @@ package openapi3gen
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"sort"
"strings" "strings"
"gitea.dev/modules/json" "gitea.dev/modules/json"
@@ -25,10 +26,12 @@ var rxDeprecated = regexp.MustCompile(`(?i)(?:^|[\n.;])\s*deprecated\b`)
// Gitea-specific post-processing: file-schema fixups, URI formats, // Gitea-specific post-processing: file-schema fixups, URI formats,
// deprecated flags, and shared-enum extraction. // deprecated flags, and shared-enum extraction.
// //
// astEnumMap is a value-set-key → Go-type-name map (built by // astEnumMap is a value-set-key → Go-type-name(s) map (built by
// ScanSwaggerEnumTypes). If a shared enum in the spec has no entry in the // ScanSwaggerEnumTypes). When a value set is shared by multiple Go types,
// map, Convert returns an error — no fallback naming. // per-property disambiguation uses the x-go-enum-desc extension. If a shared
func Convert(swaggerJSON []byte, astEnumMap map[string]string) (*openapi3.T, error) { // enum in the spec has no matching entry, Convert returns an error — no
// fallback naming.
func Convert(swaggerJSON []byte, astEnumMap map[string][]string) (*openapi3.T, error) {
var swagger2 openapi2.T var swagger2 openapi2.T
if err := json.Unmarshal(swaggerJSON, &swagger2); err != nil { if err := json.Unmarshal(swaggerJSON, &swagger2); err != nil {
return nil, fmt.Errorf("parsing swagger 2.0: %w", err) return nil, fmt.Errorf("parsing swagger 2.0: %w", err)
@@ -176,12 +179,24 @@ type enumUsage struct {
// If the derived enum name collides with an existing component schema, or // If the derived enum name collides with an existing component schema, or
// no // swagger:enum annotation matches the value set, generation aborts // no // swagger:enum annotation matches the value set, generation aborts
// with an actionable error — there are no silent fallbacks. // with an actionable error — there are no silent fallbacks.
func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error { func extractSharedEnums(doc *openapi3.T, astEnumMap map[string][]string) error {
if doc.Components == nil { if doc.Components == nil {
return nil return nil
} }
enumGroups := map[string][]enumUsage{} type groupKey struct {
valueSet string
typeName string
}
enumGroups := map[groupKey][]enumUsage{}
groupOrder := []groupKey{} // deterministic iteration
addUsage := func(key groupKey, u enumUsage) {
if _, seen := enumGroups[key]; !seen {
groupOrder = append(groupOrder, key)
}
enumGroups[key] = append(enumGroups[key], u)
}
for schemaName, schemaRef := range doc.Components.Schemas { for schemaName, schemaRef := range doc.Components.Schemas {
if schemaRef.Value == nil { if schemaRef.Value == nil {
@@ -192,24 +207,31 @@ func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error {
continue continue
} }
if len(propRef.Value.Enum) > 1 && propRef.Value.Type.Is("string") { if len(propRef.Value.Enum) > 1 && propRef.Value.Type.Is("string") {
key := EnumKey(propRef.Value.Enum) key := groupKey{
enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, false}) valueSet: EnumKey(propRef.Value.Enum),
typeName: extractEnumTypeName(propRef.Value, astEnumMap),
}
addUsage(key, enumUsage{schemaName, propName, propRef, false})
} }
if propRef.Value.Type.Is("array") && propRef.Value.Items != nil && if propRef.Value.Type.Is("array") && propRef.Value.Items != nil &&
propRef.Value.Items.Value != nil && propRef.Value.Items.Ref == "" && propRef.Value.Items.Value != nil && propRef.Value.Items.Ref == "" &&
len(propRef.Value.Items.Value.Enum) > 1 && propRef.Value.Items.Value.Type.Is("string") { len(propRef.Value.Items.Value.Enum) > 1 && propRef.Value.Items.Value.Type.Is("string") {
key := EnumKey(propRef.Value.Items.Value.Enum) key := groupKey{
enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, true}) valueSet: EnumKey(propRef.Value.Items.Value.Enum),
typeName: extractEnumTypeName(propRef.Value.Items.Value, astEnumMap),
}
addUsage(key, enumUsage{schemaName, propName, propRef, true})
} }
} }
} }
for key, usages := range enumGroups { for _, key := range groupOrder {
usages := enumGroups[key]
if len(usages) < 2 { if len(usages) < 2 {
continue continue
} }
enumName, err := deriveEnumName(key, usages, astEnumMap) enumName, err := deriveEnumName(key.valueSet, key.typeName, usages, astEnumMap)
if err != nil { if err != nil {
return err return err
} }
@@ -257,12 +279,17 @@ func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error {
return nil return nil
} }
// deriveEnumName looks up a shared enum's Go type name from astEnumMap by // deriveEnumName looks up a shared enum's Go type name. If typeName is
// value-set key. If no annotation matches, returns an error identifying the // non-empty (because we recovered it from x-go-enum-desc), it is used
// offending properties and the fix. // directly. Otherwise the value-set must map to exactly one known type. On
func deriveEnumName(key string, usages []enumUsage, astEnumMap map[string]string) (string, error) { // failure, returns an error identifying the offending properties.
if name, ok := astEnumMap[key]; ok { func deriveEnumName(key, typeName string, usages []enumUsage, astEnumMap map[string][]string) (string, error) {
return name, nil if typeName != "" {
return typeName, nil
}
names := astEnumMap[key]
if len(names) == 1 {
return names[0], nil
} }
props := map[string]bool{} props := map[string]bool{}
@@ -273,9 +300,87 @@ func deriveEnumName(key string, usages []enumUsage, astEnumMap map[string]string
for p := range props { for p := range props {
propList = append(propList, p) propList = append(propList, p)
} }
if len(names) > 1 {
return "", fmt.Errorf(
"value-set %q is shared by multiple swagger:enum types %v and could not be disambiguated for properties: %v; "+
"ensure go-swagger emits x-go-enum-desc for those properties",
key, names, propList,
)
}
return "", fmt.Errorf( return "", fmt.Errorf(
"no swagger:enum annotation matches value-set %q used by %d properties: %v; "+ "no swagger:enum annotation matches value-set %q used by %d properties: %v; "+
"fix by adding a named string type with // swagger:enum to modules/structs or modules/commitstatus", "fix by adding a named string type with // swagger:enum to modules/structs or modules/commitstatus",
key, len(usages), propList, key, len(usages), propList,
) )
} }
// extractEnumTypeName recovers the Go type name a schema's enum came from by
// parsing the property's x-go-enum-desc extension. go-swagger emits one line
// per value as "<value> <ConstName>[ <free text>]"; the type is the longest
// common prefix of the const names, narrowed to the candidate set in
// astEnumMap. Returns "" if extraction is inconclusive.
func extractEnumTypeName(s *openapi3.Schema, astEnumMap map[string][]string) string {
if s == nil || s.Extensions == nil {
return ""
}
raw, ok := s.Extensions["x-go-enum-desc"]
if !ok {
return ""
}
desc, ok := raw.(string)
if !ok {
return ""
}
candidates := astEnumMap[EnumKey(s.Enum)]
if len(candidates) == 0 {
return ""
}
// Collect the const names (second whitespace-separated field per line).
var consts []string
for line := range strings.SplitSeq(desc, "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
consts = append(consts, fields[1])
}
}
if len(consts) == 0 {
return ""
}
// A candidate matches when it is a prefix of every const name AND the
// first character after the prefix is an uppercase ASCII letter — this
// rejects e.g. "Alpha" matching "Alphabet" (suffix "bet" starts lower)
// while still accepting both "Alpha" and "AlphaPlus" against "AlphaPlusX"
// (both prefixes valid). The most specific (longest) wins; ties return
// "" so deriveEnumName surfaces the ambiguity rather than silently
// picking a winner.
ordered := append([]string(nil), candidates...)
sort.Slice(ordered, func(i, j int) bool { return len(ordered[i]) > len(ordered[j]) })
var matches []string
for _, name := range ordered {
ok := true
for _, c := range consts {
if !strings.HasPrefix(c, name) {
ok = false
break
}
suffix := c[len(name):]
// Empty suffix means the const name exactly equals the type name — valid exact match.
// A non-empty suffix must begin with an uppercase letter to reject incidental
// prefix matches (e.g. "Alpha" should not match "Alphabet").
if len(suffix) > 0 && (suffix[0] < 'A' || suffix[0] > 'Z') {
ok = false
break
}
}
if ok {
matches = append(matches, name)
}
}
if len(matches) == 0 {
return ""
}
if len(matches) > 1 && len(matches[0]) == len(matches[1]) {
return ""
}
return matches[0]
}

View File

@@ -12,9 +12,9 @@ import (
func TestDeriveEnumName_hit(t *testing.T) { func TestDeriveEnumName_hit(t *testing.T) {
key := EnumKey([]any{"red", "green", "blue"}) key := EnumKey([]any{"red", "green", "blue"})
astMap := map[string]string{key: "Color"} astMap := map[string][]string{key: {"Color"}}
usages := []enumUsage{{schemaName: "Paint", propName: "color"}} usages := []enumUsage{{schemaName: "Paint", propName: "color"}}
got, err := deriveEnumName(key, usages, astMap) got, err := deriveEnumName(key, "", usages, astMap)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -26,7 +26,7 @@ func TestDeriveEnumName_hit(t *testing.T) {
func TestDeriveEnumName_miss(t *testing.T) { func TestDeriveEnumName_miss(t *testing.T) {
key := EnumKey([]any{"x", "y"}) key := EnumKey([]any{"x", "y"})
usages := []enumUsage{{schemaName: "Thing", propName: "kind"}} usages := []enumUsage{{schemaName: "Thing", propName: "kind"}}
_, err := deriveEnumName(key, usages, map[string]string{}) _, err := deriveEnumName(key, "", usages, map[string][]string{})
if err == nil { if err == nil {
t.Fatal("expected miss error, got nil") t.Fatal("expected miss error, got nil")
} }
@@ -64,7 +64,7 @@ func TestExtractSharedEnums_usesASTMap(t *testing.T) {
}, },
}, },
} }
astMap := map[string]string{EnumKey([]any{"red", "green", "blue"}): "Color"} astMap := map[string][]string{EnumKey([]any{"red", "green", "blue"}): {"Color"}}
if err := extractSharedEnums(doc, astMap); err != nil { if err := extractSharedEnums(doc, astMap); err != nil {
t.Fatalf("extractSharedEnums: %v", err) t.Fatalf("extractSharedEnums: %v", err)
} }
@@ -139,6 +139,54 @@ func TestFixFileSchemas_recursesIntoNested(t *testing.T) {
} }
} }
func TestExtractEnumTypeName_TeamVisibility(t *testing.T) {
enum := []any{"public", "limited", "private"}
key := EnumKey(enum)
astMap := map[string][]string{key: {"UserVisibility", "TeamVisibility"}}
schema := &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: enum,
Extensions: map[string]any{
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
},
}
if got := extractEnumTypeName(schema, astMap); got != "TeamVisibility" {
t.Fatalf("got %q, want %q", got, "TeamVisibility")
}
}
func TestExtractEnumTypeName_ambiguousPrefixTie(t *testing.T) {
enum := []any{"one", "two"}
key := EnumKey(enum)
astMap := map[string][]string{key: {"AB", "AC"}}
schema := &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: enum,
Extensions: map[string]any{
"x-go-enum-desc": "one ABOne\ntwo ACTwo",
},
}
if got := extractEnumTypeName(schema, astMap); got != "" {
t.Fatalf("got %q, want empty string for ambiguous tie", got)
}
}
func TestExtractEnumTypeName_rejectsIncidentalPrefix(t *testing.T) {
enum := []any{"a", "b"}
key := EnumKey(enum)
astMap := map[string][]string{key: {"Alpha", "Alphabet"}}
schema := &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: enum,
Extensions: map[string]any{
"x-go-enum-desc": "a AlphabetA\nb AlphabetB",
},
}
if got := extractEnumTypeName(schema, astMap); got != "Alphabet" {
t.Fatalf("got %q, want %q", got, "Alphabet")
}
}
func TestExtractSharedEnums_missReturnsError(t *testing.T) { func TestExtractSharedEnums_missReturnsError(t *testing.T) {
doc := &openapi3.T{ doc := &openapi3.T{
Components: &openapi3.Components{ Components: &openapi3.Components{
@@ -164,7 +212,7 @@ func TestExtractSharedEnums_missReturnsError(t *testing.T) {
}, },
}, },
} }
if err := extractSharedEnums(doc, map[string]string{}); err == nil { if err := extractSharedEnums(doc, map[string][]string{}); err == nil {
t.Fatal("expected miss error") t.Fatal("expected miss error")
} }
} }

View File

@@ -34,13 +34,16 @@ func EnumKey(values []any) string {
var rxSwaggerEnum = regexp.MustCompile(`swagger:enum\s+(\w+)`) var rxSwaggerEnum = regexp.MustCompile(`swagger:enum\s+(\w+)`)
// ScanSwaggerEnumTypes walks .go files under each dir and returns a map from // ScanSwaggerEnumTypes walks .go files under each dir and returns a map from
// a canonical value-set key (see EnumKey) to the Go type name declared with // a canonical value-set key (see EnumKey) to the Go type names declared with
// // swagger:enum TypeName. // // swagger:enum TypeName. Multiple type names per key are allowed (e.g.
// distinct enum types that happen to share a value set such as
// {public, limited, private}); callers must disambiguate per-usage (typically
// by parsing the property's x-go-enum-desc extension to recover the const
// type prefix).
// //
// Returns an error on parse failure, on an annotation for a type whose // Returns an error on parse failure or on an annotation for a type whose
// constants can't be extracted, or on value-set collisions between two // constants can't be extracted.
// different enum types. func ScanSwaggerEnumTypes(dirs []string) (map[string][]string, error) {
func ScanSwaggerEnumTypes(dirs []string) (map[string]string, error) {
fset := token.NewFileSet() fset := token.NewFileSet()
parsed := []*ast.File{} parsed := []*ast.File{}
@@ -92,17 +95,18 @@ func ScanSwaggerEnumTypes(dirs []string) (map[string]string, error) {
} }
} }
result := map[string]string{} result := map[string][]string{}
for typeName := range enumTypes { for typeName := range enumTypes {
values, ok := enumValues[typeName] values, ok := enumValues[typeName]
if !ok || len(values) == 0 { if !ok || len(values) == 0 {
return nil, fmt.Errorf("swagger:enum %s has no const block with typed string values", typeName) return nil, fmt.Errorf("swagger:enum %s has no const block with typed string values", typeName)
} }
key := EnumKey(values) key := EnumKey(values)
if existing, ok := result[key]; ok && existing != typeName { result[key] = append(result[key], typeName)
return nil, fmt.Errorf("swagger:enum value-set collision: %s and %s both use %q", existing, typeName, key) }
} for key, names := range result {
result[key] = typeName sort.Strings(names)
result[key] = names
} }
return result, nil return result, nil
} }

View File

@@ -6,10 +6,19 @@ package openapi3gen
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
) )
func single(got map[string][]string, key string) string {
v := got[key]
if len(v) != 1 {
return ""
}
return v[0]
}
func TestEnumKey_sortsAndJoins(t *testing.T) { func TestEnumKey_sortsAndJoins(t *testing.T) {
key := EnumKey([]any{"b", "a", "c"}) key := EnumKey([]any{"b", "a", "c"})
if key != "a|b|c" { if key != "a|b|c" {
@@ -47,7 +56,7 @@ const (
t.Fatalf("ScanSwaggerEnumTypes: %v", err) t.Fatalf("ScanSwaggerEnumTypes: %v", err)
} }
wantKey := EnumKey([]any{"red", "green", "blue"}) wantKey := EnumKey([]any{"red", "green", "blue"})
if got[wantKey] != "Color" { if single(got, wantKey) != "Color" {
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Color") t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Color")
} }
} }
@@ -98,13 +107,14 @@ const (
t.Fatal(err) t.Fatal(err)
} }
_, err := ScanSwaggerEnumTypes([]string{dir}) got, err := ScanSwaggerEnumTypes([]string{dir})
if err == nil { if err != nil {
t.Fatal("expected collision error, got nil") t.Fatalf("ScanSwaggerEnumTypes: %v", err)
} }
msg := err.Error() key := EnumKey([]any{"x", "y"})
if !strings.Contains(msg, "Alpha") || !strings.Contains(msg, "Beta") { names := got[key]
t.Fatalf("error %q should mention both Alpha and Beta", msg) if !slices.Equal(names, []string{"Alpha", "Beta"}) {
t.Fatalf("map[%q] = %v, want [Alpha Beta]", key, names)
} }
} }
@@ -168,7 +178,7 @@ type Hue string
t.Fatalf("ScanSwaggerEnumTypes: %v", err) t.Fatalf("ScanSwaggerEnumTypes: %v", err)
} }
wantKey := EnumKey([]any{"a", "b"}) wantKey := EnumKey([]any{"a", "b"})
if got[wantKey] != "Hue" { if single(got, wantKey) != "Hue" {
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Hue") t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Hue")
} }
} }
@@ -194,7 +204,7 @@ type Shade string
t.Fatalf("ScanSwaggerEnumTypes: %v", err) t.Fatalf("ScanSwaggerEnumTypes: %v", err)
} }
wantKey := EnumKey([]any{"dark", "light"}) wantKey := EnumKey([]any{"dark", "light"})
if got[wantKey] != "Shade" { if single(got, wantKey) != "Shade" {
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Shade") t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Shade")
} }
} }
@@ -230,10 +240,10 @@ const (
} }
colorKey := EnumKey([]any{"red", "blue"}) colorKey := EnumKey([]any{"red", "blue"})
shadeKey := EnumKey([]any{"dark", "light"}) shadeKey := EnumKey([]any{"dark", "light"})
if got[colorKey] != "Color" { if single(got, colorKey) != "Color" {
t.Fatalf("Color: map[%q] = %q, want %q", colorKey, got[colorKey], "Color") t.Fatalf("Color: map[%q] = %q, want %q", colorKey, got[colorKey], "Color")
} }
if got[shadeKey] != "Shade" { if single(got, shadeKey) != "Shade" {
t.Fatalf("Shade: map[%q] = %q, want %q", shadeKey, got[shadeKey], "Shade") t.Fatalf("Shade: map[%q] = %q, want %q", shadeKey, got[shadeKey], "Shade")
} }
} }

View File

@@ -28,9 +28,8 @@ var (
registeredInitFuncs []func() error registeredInitFuncs []func() error
) )
// Engine represents a xorm engine or session. // SQLSession represents a common interface for engine and session to execute SQLs
type Engine interface { type SQLSession interface {
Table(tableNameOrBean any) *xorm.Session
Count(...any) (int64, error) Count(...any) (int64, error)
Decr(column string, arg ...any) *xorm.Session Decr(column string, arg ...any) *xorm.Session
Delete(...any) (int64, error) Delete(...any) (int64, error)
@@ -52,7 +51,6 @@ type Engine interface {
Limit(limit int, start ...int) *xorm.Session Limit(limit int, start ...int) *xorm.Session
NoAutoTime() *xorm.Session NoAutoTime() *xorm.Session
SumInt(bean any, columnName string) (res int64, err error) SumInt(bean any, columnName string) (res int64, err error)
Sync(...any) error
Select(string) *xorm.Session Select(string) *xorm.Session
SetExpr(string, any) *xorm.Session SetExpr(string, any) *xorm.Session
NotIn(string, ...any) *xorm.Session NotIn(string, ...any) *xorm.Session
@@ -61,12 +59,20 @@ type Engine interface {
Distinct(...string) *xorm.Session Distinct(...string) *xorm.Session
Query(...any) ([]map[string][]byte, error) Query(...any) ([]map[string][]byte, error)
Cols(...string) *xorm.Session Cols(...string) *xorm.Session
Table(tableNameOrBean any) *xorm.Session
Context(ctx context.Context) *xorm.Session Context(ctx context.Context) *xorm.Session
Ping() error QueryInterface(sqlOrArgs ...any) ([]map[string]any, error)
IsTableExist(tableNameOrBean any) (bool, error) IsTableExist(tableNameOrBean any) (bool, error)
} }
// Session represents a xorm session interface, used as an abstraction over *xorm.Session. // Engine represents a xorm engine
type Engine interface {
SQLSession
Sync(...any) error
Ping() error
}
// Session represents a xorm session interface
type Session interface { type Session interface {
Engine Engine
And(query any, args ...any) *xorm.Session And(query any, args ...any) *xorm.Session
@@ -89,7 +95,6 @@ type EngineMigration interface {
Dialect() dialects.Dialect Dialect() dialects.Dialect
DropTables(beans ...any) error DropTables(beans ...any) error
NewSession() *xorm.Session NewSession() *xorm.Session
QueryInterface(sqlOrArgs ...any) ([]map[string]any, error)
SetMapper(mapper names.Mapper) SetMapper(mapper names.Mapper)
SyncWithOptions(opts xorm.SyncOptions, beans ...any) (*xorm.SyncResult, error) SyncWithOptions(opts xorm.SyncOptions, beans ...any) (*xorm.SyncResult, error)
TableInfo(bean any) (*schemas.Table, error) TableInfo(bean any) (*schemas.Table, error)

View File

@@ -24,10 +24,9 @@ type Paginator interface {
} }
// SetSessionPagination sets pagination for a database session // SetSessionPagination sets pagination for a database session
func SetSessionPagination(sess Engine, p Paginator) Session { func SetSessionPagination(sess Engine, p Paginator) {
skip, take := p.GetSkipTake() skip, take := p.GetSkipTake()
sess.Limit(take, skip)
return sess.Limit(take, skip)
} }
// ListOptions options to paginate results // ListOptions options to paginate results

View File

@@ -181,7 +181,7 @@ func PullRequests(ctx context.Context, baseRepoID int64, opts *PullRequestsOptio
findSession := listPullRequestStatement(ctx, baseRepoID, opts) findSession := listPullRequestStatement(ctx, baseRepoID, opts)
applySorts(findSession, opts.SortType, 0) applySorts(findSession, opts.SortType, 0)
findSession = db.SetSessionPagination(findSession, opts) db.SetSessionPagination(findSession, opts)
prs := make([]*PullRequest, 0, opts.PageSize) prs := make([]*PullRequest, 0, opts.PageSize)
found := findSession.Find(&prs) found := findSession.Find(&prs)
return prs, maxResults, found return prs, maxResults, found

View File

@@ -414,6 +414,7 @@ func prepareMigrationTasks() []*migration {
newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner), newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner),
newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob), newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob),
newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable), newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable),
newMigration(337, "Add visibility to team", v1_27.AddVisibilityToTeam),
} }
return preparedMigrations return preparedMigrations
} }

View File

@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"gitea.dev/models/db"
"xorm.io/xorm"
)
type VisibleType int
type teamWithVisibility struct {
Visibility VisibleType `xorm:"NOT NULL DEFAULT 2"`
}
func (teamWithVisibility) TableName() string {
return "team"
}
func AddVisibilityToTeam(x db.EngineMigration) error {
if _, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreDropIndices: true,
IgnoreConstrains: true,
}, new(teamWithVisibility)); err != nil {
return err
}
// Owner teams must remain listable to all org members; new orgs create
// them as "limited", so make existing owner teams limited too.
// Filter on authorize=4 (AccessModeOwner) so a user-created team that
// happens to share the name "owners" is not accidentally affected.
_, err := x.Exec("UPDATE `team` SET visibility = ? WHERE lower_name = ? AND authorize = ?", 1, "owners", 4)
return err
}

View File

@@ -370,6 +370,7 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
NumMembers: 1, NumMembers: 1,
IncludesAllRepositories: true, IncludesAllRepositories: true,
CanCreateOrgRepo: true, CanCreateOrgRepo: true,
Visibility: structs.VisibleTypeLimited,
} }
if err = db.Insert(ctx, t); err != nil { if err = db.Insert(ctx, t); err != nil {
return fmt.Errorf("insert owner team: %w", err) return fmt.Errorf("insert owner team: %w", err)

View File

@@ -14,6 +14,7 @@ import (
"gitea.dev/models/unit" "gitea.dev/models/unit"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/structs"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"xorm.io/builder" "xorm.io/builder"
@@ -81,9 +82,36 @@ type Team struct {
Members []*user_model.User `xorm:"-"` Members []*user_model.User `xorm:"-"`
NumRepos int NumRepos int
NumMembers int NumMembers int
Units []*TeamUnit `xorm:"-"` Units []*TeamUnit `xorm:"-"`
IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"` IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"`
CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"` CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"`
Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 2"`
}
func (t *Team) IsPublic() bool { return t.Visibility.IsPublic() }
func (t *Team) IsLimited() bool { return t.Visibility.IsLimited() }
func (t *Team) IsPrivate() bool { return t.Visibility.IsPrivate() }
// CanNonMemberReadMeta reports whether a non-member, non-owner doer may read
// the team's metadata, based on the team's visibility tier and the parent org's
// visibility. Privileged callers (site admins, org owners, team members) are
// decided by the caller before reaching here.
func (t *Team) CanNonMemberReadMeta(ctx context.Context, org, doer *user_model.User) (bool, error) {
switch t.Visibility {
case structs.VisibleTypePublic:
return HasOrgOrUserVisible(ctx, org, doer), nil
case structs.VisibleTypeLimited:
return IsOrganizationMember(ctx, t.OrgID, doer.ID)
default:
return false, nil
}
}
func NormalizeTeamVisibility(s string) structs.VisibleType {
if vt, ok := structs.VisibilityModes[s]; ok {
return vt
}
return structs.VisibleTypePrivate
} }
func init() { func init() {

View File

@@ -10,6 +10,8 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/models/perm" "gitea.dev/models/perm"
"gitea.dev/models/unit" "gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
"xorm.io/builder" "xorm.io/builder"
) )
@@ -50,9 +52,15 @@ type SearchTeamOptions struct {
Keyword string Keyword string
OrgID int64 OrgID int64
IncludeDesc bool IncludeDesc bool
// IncludeVisibilities, when combined with UserID, also returns teams whose
// visibility is in this list, even if UserID is not a member. Typical values:
// - {limited,public} for org members
// - {public} for signed-in users who are not org members
// Leave empty to return only teams the user is a member of.
IncludeVisibilities []structs.VisibleType
} }
func (opts *SearchTeamOptions) toCond() builder.Cond { func (opts *SearchTeamOptions) applyToSession(sess db.SQLSession) {
cond := builder.NewCond() cond := builder.NewCond()
if len(opts.Keyword) > 0 { if len(opts.Keyword) > 0 {
@@ -68,11 +76,51 @@ func (opts *SearchTeamOptions) toCond() builder.Cond {
cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID}) cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID})
} }
if opts.UserID > 0 { switch {
case opts.UserID > 0 && len(opts.IncludeVisibilities) > 0:
sess = sess.Join("LEFT", "team_user", "team_user.team_id = team.id AND team_user.uid = ?", opts.UserID)
cond = cond.And(builder.Or(
builder.Eq{"team_user.uid": opts.UserID},
builder.In("`team`.visibility", opts.IncludeVisibilities),
))
case opts.UserID > 0:
sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id")
cond = cond.And(builder.Eq{"team_user.uid": opts.UserID}) cond = cond.And(builder.Eq{"team_user.uid": opts.UserID})
case len(opts.IncludeVisibilities) > 0:
cond = cond.And(builder.In("`team`.visibility", opts.IncludeVisibilities))
} }
sess.Where(cond)
}
return cond func VisibleTeamVisibilitiesFor(isOrgMember, isSignedIn bool) []structs.VisibleType {
switch {
case isOrgMember:
return []structs.VisibleType{structs.VisibleTypeLimited, structs.VisibleTypePublic}
case isSignedIn:
return []structs.VisibleType{structs.VisibleTypePublic}
default:
return nil
}
}
func ApplyTeamListFilter(ctx context.Context, orgID int64, viewer *user_model.User, isSignedIn bool, opts *SearchTeamOptions) error {
if viewer.IsAdmin {
return nil
}
isOwner, err := IsOrganizationOwner(ctx, orgID, viewer.ID)
if err != nil {
return err
}
if isOwner {
return nil
}
isOrgMember, err := IsOrganizationMember(ctx, orgID, viewer.ID)
if err != nil {
return err
}
opts.UserID = viewer.ID
opts.IncludeVisibilities = VisibleTeamVisibilitiesFor(isOrgMember, isSignedIn)
return nil
} }
// SearchTeam search for teams. Caller is responsible to check permissions. // SearchTeam search for teams. Caller is responsible to check permissions.
@@ -80,15 +128,12 @@ func SearchTeam(ctx context.Context, opts *SearchTeamOptions) (TeamList, int64,
sess := db.GetEngine(ctx) sess := db.GetEngine(ctx)
opts.SetDefaultValues() opts.SetDefaultValues()
cond := opts.toCond() opts.applyToSession(sess)
if opts.UserID > 0 {
sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id")
}
db.SetSessionPagination(sess, opts) db.SetSessionPagination(sess, opts)
teams := make([]*Team, 0, opts.PageSize) teams := make([]*Team, 0, opts.PageSize)
count, err := sess.Where(cond).OrderBy("CASE WHEN name=? THEN '' ELSE lower_name END", OwnerTeamName).FindAndCount(&teams) count, err := sess.OrderBy("CASE WHEN name=? THEN '' ELSE lower_name END", OwnerTeamName).FindAndCount(&teams)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }

View File

@@ -10,6 +10,8 @@ import (
"gitea.dev/models/organization" "gitea.dev/models/organization"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -38,6 +40,43 @@ func TestTeam_IsMember(t *testing.T) {
assert.False(t, team.IsMember(t.Context(), unittest.NonexistentID)) assert.False(t, team.IsMember(t.Context(), unittest.NonexistentID))
} }
func TestTeam_CanNonMemberReadMeta(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // public org
org35 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 35}) // private org
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // member of org 3 and org 35
outsider := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // member of neither org
test := func(name string, team *organization.Team, org, doer *user_model.User, expected bool) {
t.Run(name, func(t *testing.T) {
ok, err := team.CanNonMemberReadMeta(t.Context(), org, doer)
assert.NoError(t, err)
assert.Equal(t, expected, ok)
})
}
// Public team is gated only by the parent org's visibility.
publicTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypePublic}
test("public team, public org, member", publicTeam, org3, member, true)
test("public team, public org, outsider", publicTeam, org3, outsider, true)
// Public team inside a private org: only org members may see it.
publicTeamPrivOrg := &organization.Team{OrgID: 35, Visibility: structs.VisibleTypePublic}
test("public team, private org, org member", publicTeamPrivOrg, org35, member, true)
test("public team, private org, outsider", publicTeamPrivOrg, org35, outsider, false)
// Limited team: any org member, but never outsiders.
limitedTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypeLimited}
test("limited team, org member", limitedTeam, org3, member, true)
test("limited team, outsider", limitedTeam, org3, outsider, false)
// Private team is never visible to non-members; members/owners are admitted by the caller.
privateTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypePrivate}
test("private team, org member", privateTeam, org3, member, false)
test("private team, outsider", privateTeam, org3, outsider, false)
}
func TestTeam_GetRepositories(t *testing.T) { func TestTeam_GetRepositories(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
@@ -172,6 +211,52 @@ func TestGetUserOrgTeams(t *testing.T) {
test(3, unittest.NonexistentID) test(3, unittest.NonexistentID)
} }
func TestSearchTeamIncludeVisible(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
const orgID int64 = 3
// User 5 is an org member but only belongs to team 1 (Owners) — make sure
// they don't see team 2 (default private) but do see a freshly added
// limited team they are not a member of.
visible := &organization.Team{
OrgID: orgID,
LowerName: "visible-team",
Name: "visible-team",
AccessMode: 1, // read
Visibility: structs.VisibleTypeLimited,
}
assert.NoError(t, db.Insert(t.Context(), visible))
teams, _, err := organization.SearchTeam(t.Context(), &organization.SearchTeamOptions{
OrgID: orgID,
UserID: 2,
IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(true, true),
})
assert.NoError(t, err)
ids := make(map[int64]bool, len(teams))
for _, team := range teams {
assert.Equal(t, orgID, team.OrgID)
ids[team.ID] = true
}
// user 2 is in team 1 and team 2 in org 3, plus should see the new visible team.
assert.True(t, ids[1], "expected to see team 1 (member)")
assert.True(t, ids[2], "expected to see team 2 (member)")
assert.True(t, ids[visible.ID], "expected to see visible team")
// user 5 is only an org member in team 1, must not see secret team 2 but must see the visible one.
teams, _, err = organization.SearchTeam(t.Context(), &organization.SearchTeamOptions{
OrgID: orgID,
UserID: 5,
IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(true, true),
})
assert.NoError(t, err)
ids = make(map[int64]bool, len(teams))
for _, team := range teams {
ids[team.ID] = true
}
assert.False(t, ids[2], "user 5 must not see private team 2")
assert.True(t, ids[visible.ID], "user 5 must see the limited team")
}
func TestHasTeamRepo(t *testing.T) { func TestHasTeamRepo(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())

View File

@@ -783,7 +783,8 @@ func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (Repositor
sess = sess.Where(cond).OrderBy(opts.OrderBy.String()) sess = sess.Where(cond).OrderBy(opts.OrderBy.String())
repos := make(RepositoryList, 0, opts.PageSize) repos := make(RepositoryList, 0, opts.PageSize)
return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos) db.SetSessionPagination(sess, &opts)
return repos, count, sess.Find(&repos)
} }
func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) { func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) {

View File

@@ -4,6 +4,20 @@
package structs package structs
// TeamVisibility controls who can list a team within its organization.
// - "public": visible to any signed-in user (still bounded by org visibility)
// - "limited": visible to any member of the parent organization
// - "private": visible only to team members and org owners
//
// swagger:enum TeamVisibility
type TeamVisibility string
const (
TeamVisibilityPublic TeamVisibility = "public"
TeamVisibilityLimited TeamVisibility = "limited"
TeamVisibilityPrivate TeamVisibility = "private"
)
// Team represents a team in an organization // Team represents a team in an organization
type Team struct { type Team struct {
// The unique identifier of the team // The unique identifier of the team
@@ -24,6 +38,11 @@ type Team struct {
UnitsMap map[string]string `json:"units_map"` UnitsMap map[string]string `json:"units_map"`
// Whether the team can create repositories in the organization // Whether the team can create repositories in the organization
CanCreateOrgRepo bool `json:"can_create_org_repo"` CanCreateOrgRepo bool `json:"can_create_org_repo"`
// Team visibility within the organization. "private" teams are only
// listable by members and org owners; "limited" teams are listable by
// any organization member; "public" teams are listable by any signed-in
// user.
Visibility TeamVisibility `json:"visibility"`
} }
// CreateTeamOption options for creating a team // CreateTeamOption options for creating a team
@@ -42,6 +61,8 @@ type CreateTeamOption struct {
UnitsMap map[string]string `json:"units_map"` UnitsMap map[string]string `json:"units_map"`
// Whether the team can create repositories in the organization // Whether the team can create repositories in the organization
CanCreateOrgRepo bool `json:"can_create_org_repo"` CanCreateOrgRepo bool `json:"can_create_org_repo"`
// Team visibility within the organization. Defaults to "private".
Visibility TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
} }
// EditTeamOption options for editing a team // EditTeamOption options for editing a team
@@ -60,4 +81,7 @@ type EditTeamOption struct {
UnitsMap map[string]string `json:"units_map"` UnitsMap map[string]string `json:"units_map"`
// Whether the team can create repositories in the organization // Whether the team can create repositories in the organization
CanCreateOrgRepo *bool `json:"can_create_org_repo"` CanCreateOrgRepo *bool `json:"can_create_org_repo"`
// Team visibility within the organization. When omitted, visibility is
// left unchanged.
Visibility *TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
} }

View File

@@ -2865,6 +2865,14 @@
"org.teams.all_repositories_read_permission_desc": "This team grants <strong>Read</strong> access to <strong>all repositories</strong>: members can view and clone repositories.", "org.teams.all_repositories_read_permission_desc": "This team grants <strong>Read</strong> access to <strong>all repositories</strong>: members can view and clone repositories.",
"org.teams.all_repositories_write_permission_desc": "This team grants <strong>Write</strong> access to <strong>all repositories</strong>: members can read from and push to repositories.", "org.teams.all_repositories_write_permission_desc": "This team grants <strong>Write</strong> access to <strong>all repositories</strong>: members can read from and push to repositories.",
"org.teams.all_repositories_admin_permission_desc": "This team grants <strong>Admin</strong> access to <strong>all repositories</strong>: members can read from, push to and add collaborators to repositories.", "org.teams.all_repositories_admin_permission_desc": "This team grants <strong>Admin</strong> access to <strong>all repositories</strong>: members can read from, push to and add collaborators to repositories.",
"org.teams.visibility": "Visibility",
"org.teams.visibility_private": "Private",
"org.teams.visibility_private_helper": "Visible only to team members and organization owners.",
"org.teams.visibility_limited": "Limited",
"org.teams.visibility_limited_helper": "Visible to all members of this organization.",
"org.teams.visibility_public": "Public",
"org.teams.visibility_public_helper": "Visible to any signed-in user.",
"org.teams.owners_visibility_fixed": "The Owners team visibility cannot be changed.",
"org.teams.invite.title": "You have been invited to join team <strong>%s</strong> in organization <strong>%s</strong>.", "org.teams.invite.title": "You have been invited to join team <strong>%s</strong> in organization <strong>%s</strong>.",
"org.teams.invite.by": "Invited by %s", "org.teams.invite.by": "Invited by %s",
"org.teams.invite.description": "Please click the button below to join the team.", "org.teams.invite.description": "Please click the button below to join the team.",

View File

@@ -505,41 +505,79 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
} }
} }
// reqTeamMembership user should be an team member, or a site admin func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
func reqTeamMembership() func(ctx *context.APIContext) { if ctx.IsUserSiteAdmin() {
return 0, true, true
}
if ctx.Org.Team == nil {
setting.PanicInDevOrTesting("teamAccess: unprepared context")
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
return 0, false, false
}
orgID = ctx.Org.Team.OrgID
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return 0, false, false
} else if isOwner {
return orgID, true, true
}
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return 0, false, false
}
return orgID, isTeamMember, true
}
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
} else if isOrgMember {
ctx.APIError(http.StatusForbidden, "Must be a team member")
} else {
ctx.APIErrorNotFound()
}
}
// reqTeamReadAccess allows callers who can list the team to read its metadata.
// Non-members are admitted by the team's visibility tier and parent org visibility.
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
func reqTeamReadAccess() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) { return func(ctx *context.APIContext) {
if ctx.IsUserSiteAdmin() { orgID, privileged, ok := teamAccessPrivileged(ctx)
if !ok || privileged {
return return
} }
if ctx.Org.Team == nil { if ctx.Org.Organization == nil {
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context") setting.PanicInDevOrTesting("reqTeamReadAccess: organization not loaded")
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context")) ctx.APIErrorInternal(errors.New("reqTeamReadAccess: organization not loaded"))
return return
} }
orgID := ctx.Org.Team.OrgID visible, err := ctx.Org.Team.CanNonMemberReadMeta(ctx, ctx.Org.Organization.AsUser(), ctx.Doer)
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} else if isOwner {
return
} }
if !visible {
// Not admitted by visibility: 403 for org members, 404 otherwise.
denyNonTeamMember(ctx, orgID)
}
}
}
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil { // reqTeamMembership user should be a team member, or a site admin
ctx.APIErrorInternal(err) func reqTeamMembership() func(ctx *context.APIContext) {
return return func(ctx *context.APIContext) {
} else if !isTeamMember { orgID, privileged, ok := teamAccessPrivileged(ctx)
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID) if !ok || privileged {
if err != nil {
ctx.APIErrorInternal(err)
} else if isOrgMember {
ctx.APIError(http.StatusForbidden, "Must be a team member")
} else {
ctx.APIErrorNotFound()
}
return return
} }
denyNonTeamMember(ctx, orgID)
} }
} }
@@ -649,6 +687,17 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) {
} }
return return
} }
if ctx.Org.Organization == nil {
ctx.Org.Organization, err = organization.GetOrgByID(ctx, ctx.Org.Team.OrgID)
if err != nil {
if organization.IsErrOrgNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
}
} }
} }
} }
@@ -1703,25 +1752,31 @@ func Routes() *web.Router {
}, reqToken(), reqOrgOwnership()) }, reqToken(), reqOrgOwnership())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
m.Group("/teams/{teamid}", func() { m.Group("/teams/{teamid}", func() {
m.Combo("").Get(reqToken(), org.GetTeam). m.Combo("").Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
Delete(reqToken(), reqOrgOwnership(), org.DeleteTeam) Delete(reqToken(), reqOrgOwnership(), org.DeleteTeam)
m.Group("", func() {
m.Get("", org.GetTeam)
m.Group("/members", func() {
m.Get("", reqOrgMembership(), org.GetTeamMembers)
m.Combo("/{username}").Get(reqOrgMembership(), org.GetTeamMember)
})
m.Group("/repos", func() {
m.Get("", org.GetTeamRepos)
m.Combo("/{org}/{reponame}").Get(org.GetTeamRepo)
})
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
}, reqTeamReadAccess())
m.Group("/members", func() { m.Group("/members", func() {
m.Get("", reqToken(), org.GetTeamMembers)
m.Combo("/{username}"). m.Combo("/{username}").
Get(reqToken(), org.GetTeamMember).
Put(reqToken(), reqOrgOwnership(), org.AddTeamMember). Put(reqToken(), reqOrgOwnership(), org.AddTeamMember).
Delete(reqToken(), reqOrgOwnership(), org.RemoveTeamMember) Delete(reqToken(), reqOrgOwnership(), org.RemoveTeamMember)
}) })
m.Group("/repos", func() { m.Group("/repos", func() {
m.Get("", reqToken(), org.GetTeamRepos)
m.Combo("/{org}/{reponame}"). m.Combo("/{org}/{reponame}").
Put(reqToken(), org.AddTeamRepository). Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
Delete(reqToken(), org.RemoveTeamRepository). Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository)
Get(reqToken(), org.GetTeamRepo)
}) })
m.Get("/activities/feeds", org.ListTeamActivityFeeds) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
m.Group("/admin", func() { m.Group("/admin", func() {
m.Group("/cron", func() { m.Group("/cron", func() {

View File

@@ -55,10 +55,15 @@ func ListTeams(ctx *context.APIContext) {
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
listOptions := utils.GetListOptions(ctx) listOptions := utils.GetListOptions(ctx)
teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{ opts := &organization.SearchTeamOptions{
ListOptions: listOptions, ListOptions: listOptions,
OrgID: ctx.Org.Organization.ID, OrgID: ctx.Org.Organization.ID,
}) }
if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil {
ctx.APIErrorInternal(err)
return
}
teams, count, err := organization.SearchTeam(ctx, opts)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -218,6 +223,7 @@ func CreateTeam(ctx *context.APIContext) {
IncludesAllRepositories: form.IncludesAllRepositories, IncludesAllRepositories: form.IncludesAllRepositories,
CanCreateOrgRepo: form.CanCreateOrgRepo, CanCreateOrgRepo: form.CanCreateOrgRepo,
AccessMode: teamPermission, AccessMode: teamPermission,
Visibility: organization.NormalizeTeamVisibility(string(form.Visibility)),
} }
if team.AccessMode < perm.AccessModeAdmin { if team.AccessMode < perm.AccessModeAdmin {
@@ -295,6 +301,10 @@ func EditTeam(ctx *context.APIContext) {
team.Description = *form.Description team.Description = *form.Description
} }
if form.Visibility != nil && !team.IsOwnerTeam() {
team.Visibility = organization.NormalizeTeamVisibility(string(*form.Visibility))
}
isAuthChanged := false isAuthChanged := false
isIncludeAllChanged := false isIncludeAllChanged := false
if !team.IsOwnerTeam() && len(form.Permission) != 0 { if !team.IsOwnerTeam() && len(form.Permission) != 0 {
@@ -387,15 +397,6 @@ func GetTeamMembers(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Team.OrgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
} else if !isMember && !ctx.Doer.IsAdmin {
ctx.APIErrorNotFound()
return
}
listOptions := utils.GetListOptions(ctx) listOptions := utils.GetListOptions(ctx)
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{ teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
ListOptions: listOptions, ListOptions: listOptions,
@@ -574,14 +575,20 @@ func GetTeamRepos(ctx *context.APIContext) {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
repos := make([]*api.Repository, len(teamRepos)) repos := make([]*api.Repository, 0, len(teamRepos))
for i, repo := range teamRepos { for _, repo := range teamRepos {
permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer) permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
repos[i] = convert.ToRepo(ctx, repo, permission) // A team's repo list is reachable by non-team-members through the team's
// visibility tier, so never expose repos (incl. their names) the doer
// cannot access.
if !permission.HasAnyUnitAccessOrPublicAccess() {
continue
}
repos = append(repos, convert.ToRepo(ctx, repo, permission))
} }
ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize) ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize)
ctx.SetTotalCountHeader(int64(team.NumRepos)) ctx.SetTotalCountHeader(int64(team.NumRepos))
@@ -633,6 +640,12 @@ func GetTeamRepo(ctx *context.APIContext) {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
// The team may be reachable by a non-team-member via its visibility tier;
// don't confirm the existence of a repo the doer cannot access.
if !permission.HasAnyUnitAccessOrPublicAccess() {
ctx.APIErrorNotFound()
return
}
ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission)) ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission))
} }
@@ -806,9 +819,9 @@ func SearchTeam(ctx *context.APIContext) {
ListOptions: listOptions, ListOptions: listOptions,
} }
// Only admin is allowed to search for all teams if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil {
if !ctx.Doer.IsAdmin { ctx.APIErrorInternal(err)
opts.UserID = ctx.Doer.ID return
} }
teams, maxResults, err := organization.SearchTeam(ctx, opts) teams, maxResults, err := organization.SearchTeam(ctx, opts)

View File

@@ -101,7 +101,26 @@ func home(ctx *context.Context, viewRepositories bool) {
const orgOverviewTeamsLimit = 5 const orgOverviewTeamsLimit = 5
ctx.Data["OrgOverviewMembers"] = members ctx.Data["OrgOverviewMembers"] = members
ctx.Data["OrgOverviewTeams"] = ctx.Org.Teams[:min(len(ctx.Org.Teams), orgOverviewTeamsLimit)] // The overview widget shows only teams the viewer belongs to. ctx.Org.Teams
// may include visible-but-not-joined teams (via IncludeVisibilities for
// signed-in non-members), so re-query the viewer's own membership; owners
// keep the full list they are entitled to manage.
overviewTeams := ctx.Org.Teams
if !ctx.Org.IsOwner {
overviewTeams = nil
if ctx.Org.IsMember {
overviewTeams, _, err = organization.SearchTeam(ctx, &organization.SearchTeamOptions{
OrgID: org.ID,
UserID: ctx.Doer.ID,
ListOptions: db.ListOptions{Page: 1, PageSize: orgOverviewTeamsLimit},
})
if err != nil {
ctx.ServerError("SearchTeam", err)
return
}
}
}
ctx.Data["OrgOverviewTeams"] = overviewTeams[:min(len(overviewTeams), orgOverviewTeamsLimit)]
ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0 ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0

View File

@@ -21,6 +21,7 @@ import (
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates" "gitea.dev/modules/templates"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"gitea.dev/modules/web" "gitea.dev/modules/web"
@@ -80,6 +81,8 @@ func Teams(ctx *context.Context) {
UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID), UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID),
Keyword: keyword, Keyword: keyword,
IncludeDesc: true, IncludeDesc: true,
IncludeVisibilities: util.Iif(shouldSeeAllOrgTeams, nil,
org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)),
ListOptions: db.ListOptions{Page: page, PageSize: pagingNum}, ListOptions: db.ListOptions{Page: page, PageSize: pagingNum},
} }
return org_model.SearchTeam(ctx, opts) return org_model.SearchTeam(ctx, opts)
@@ -377,6 +380,7 @@ func NewTeamPost(ctx *context.Context) {
AccessMode: teamPermission, AccessMode: teamPermission,
IncludesAllRepositories: includesAllRepositories, IncludesAllRepositories: includesAllRepositories,
CanCreateOrgRepo: form.CanCreateOrgRepo, CanCreateOrgRepo: form.CanCreateOrgRepo,
Visibility: org_model.NormalizeTeamVisibility(form.Visibility),
} }
units := make([]*org_model.TeamUnit, 0, len(unitPerms)) units := make([]*org_model.TeamUnit, 0, len(unitPerms))
@@ -477,13 +481,22 @@ func SearchTeam(ctx *context.Context) {
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
} }
shouldSeeAll, err := context.UserShouldSeeAllOrgTeams(ctx)
if err != nil {
ctx.ServerError("UserShouldSeeAllOrgTeams", err)
return
}
opts := &org_model.SearchTeamOptions{ opts := &org_model.SearchTeamOptions{
// UserID is not set because the router already requires the doer to be an org admin. Thus, we don't need to restrict to teams that the user belongs in
Keyword: ctx.FormTrim("q"), Keyword: ctx.FormTrim("q"),
OrgID: ctx.Org.Organization.ID, OrgID: ctx.Org.Organization.ID,
IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"), IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"),
ListOptions: listOptions, ListOptions: listOptions,
} }
if !shouldSeeAll {
opts.UserID = ctx.Doer.ID
opts.IncludeVisibilities = org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)
}
teams, maxResults, err := org_model.SearchTeam(ctx, opts) teams, maxResults, err := org_model.SearchTeam(ctx, opts)
if err != nil { if err != nil {
@@ -556,8 +569,11 @@ func EditTeamPost(ctx *context.Context) {
t.IncludesAllRepositories = includesAllRepositories t.IncludesAllRepositories = includesAllRepositories
} }
t.CanCreateOrgRepo = form.CanCreateOrgRepo t.CanCreateOrgRepo = form.CanCreateOrgRepo
t.Visibility = org_model.NormalizeTeamVisibility(form.Visibility)
} else { } else {
t.CanCreateOrgRepo = true t.CanCreateOrgRepo = true
// The owner team must remain listable to all org members.
t.Visibility = structs.VisibleTypeLimited
} }
t.Description = form.Description t.Description = form.Description

View File

@@ -179,20 +179,28 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
ctx.ServerError("UserShouldSeeAllOrgTeams", err) ctx.ServerError("UserShouldSeeAllOrgTeams", err)
return return
} }
if ctx.Org.IsMember { switch {
if shouldSeeAllTeams { case shouldSeeAllTeams:
ctx.Org.Teams, err = org.LoadTeams(ctx) ctx.Org.Teams, err = org.LoadTeams(ctx)
if err != nil { if err != nil {
ctx.ServerError("LoadTeams", err) ctx.ServerError("LoadTeams", err)
return return
}
} else {
ctx.Org.Teams, err = org.GetUserTeams(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("GetUserTeams", err)
return
}
} }
case ctx.IsSigned:
// Signed-in non-members still see teams whose visibility tier
// includes them (public for any signed-in user, plus limited
// for org members), and any team they directly belong to.
ctx.Org.Teams, _, err = organization.SearchTeam(ctx, &organization.SearchTeamOptions{
OrgID: org.ID,
UserID: ctx.Doer.ID,
IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, true),
})
if err != nil {
ctx.ServerError("SearchTeam", err)
return
}
}
if ctx.Org.IsMember {
ctx.Data["NumTeams"] = len(ctx.Org.Teams) ctx.Data["NumTeams"] = len(ctx.Org.Teams)
} }
@@ -203,7 +211,6 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
if strings.EqualFold(team.LowerName, teamName) { if strings.EqualFold(team.LowerName, teamName) {
teamExists = true teamExists = true
ctx.Org.Team = team ctx.Org.Team = team
ctx.Org.IsTeamMember = true
ctx.Data["Team"] = ctx.Org.Team ctx.Data["Team"] = ctx.Org.Team
break break
} }
@@ -214,13 +221,24 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
return return
} }
// Membership in a visible team is not implied by its presence in
// ctx.Org.Teams; admins/org owners keep the privileged flag set
// earlier in this function.
if !ctx.Org.IsOwner {
ctx.Org.IsTeamMember, err = organization.IsTeamMember(ctx, org.ID, ctx.Org.Team.ID, ctx.Doer.ID)
if err != nil {
ctx.ServerError("IsTeamMember", err)
return
}
}
ctx.Data["IsTeamMember"] = ctx.Org.IsTeamMember ctx.Data["IsTeamMember"] = ctx.Org.IsTeamMember
if opts.RequireTeamMember && !ctx.Org.IsTeamMember { if opts.RequireTeamMember && !ctx.Org.IsTeamMember {
ctx.NotFound(err) ctx.NotFound(err)
return return
} }
ctx.Org.IsTeamAdmin = ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.HasAdminAccess() isTeamOwnerOrAdmin := ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.HasAdminAccess()
ctx.Org.IsTeamAdmin = ctx.Org.IsOwner || (ctx.Org.IsTeamMember && isTeamOwnerOrAdmin)
ctx.Data["IsTeamAdmin"] = ctx.Org.IsTeamAdmin ctx.Data["IsTeamAdmin"] = ctx.Org.IsTeamAdmin
if opts.RequireTeamAdmin && !ctx.Org.IsTeamAdmin { if opts.RequireTeamAdmin && !ctx.Org.IsTeamAdmin {
ctx.NotFound(err) ctx.NotFound(err)

View File

@@ -836,6 +836,7 @@ func ToTeams(ctx context.Context, teams []*organization.Team, loadOrgs bool) ([]
Permission: api.AccessLevelName(t.AccessMode.ToString()), Permission: api.AccessLevelName(t.AccessMode.ToString()),
Units: t.GetUnitNames(), Units: t.GetUnitNames(),
UnitsMap: t.GetUnitsMap(), UnitsMap: t.GetUnitsMap(),
Visibility: api.TeamVisibility(t.Visibility.String()),
} }
if loadOrgs { if loadOrgs {

View File

@@ -70,6 +70,7 @@ type CreateTeamForm struct {
Permission string Permission string
RepoAccess string RepoAccess string
CanCreateOrgRepo bool CanCreateOrgRepo bool
Visibility string `binding:"OmitEmpty;In(public,limited,private)"`
} }
// Validate validates the fields // Validate validates the fields

View File

@@ -110,7 +110,7 @@ func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeA
sess := db.GetEngine(ctx) sess := db.GetEngine(ctx)
if _, err = sess.ID(t.ID).Cols("name", "lower_name", "description", if _, err = sess.ID(t.ID).Cols("name", "lower_name", "description",
"can_create_org_repo", "authorize", "includes_all_repositories").Update(t); err != nil { "can_create_org_repo", "authorize", "includes_all_repositories", "visibility").Update(t); err != nil {
return fmt.Errorf("update: %w", err) return fmt.Errorf("update: %w", err)
} }

View File

@@ -12,10 +12,10 @@
{{template "base/alert" .}} {{template "base/alert" .}}
<div class="required field {{if .Err_TeamName}}error{{end}}"> <div class="required field {{if .Err_TeamName}}error{{end}}">
<label for="team_name">{{ctx.Locale.Tr "org.team_name"}}</label> <label for="team_name">{{ctx.Locale.Tr "org.team_name"}}</label>
{{if eq .Team.LowerName "owners"}} {{if .Team.IsOwnerTeam}}
<input type="hidden" name="team_name" value="{{.Team.Name}}"> <input type="hidden" name="team_name" value="{{.Team.Name}}">
{{end}} {{end}}
<input id="team_name" name="team_name" value="{{.Team.Name}}" required {{if eq .Team.LowerName "owners"}}disabled{{end}} autofocus> <input id="team_name" name="team_name" value="{{.Team.Name}}" required {{if .Team.IsOwnerTeam}}disabled{{end}} autofocus>
<span class="help">{{ctx.Locale.Tr "org.team_name_helper"}}</span> <span class="help">{{ctx.Locale.Tr "org.team_name_helper"}}</span>
</div> </div>
<div class="field {{if .Err_Description}}error{{end}}"> <div class="field {{if .Err_Description}}error{{end}}">
@@ -23,7 +23,47 @@
<input id="description" name="description" value="{{.Team.Description}}" maxlength="255"> <input id="description" name="description" value="{{.Team.Description}}" maxlength="255">
<span class="help">{{ctx.Locale.Tr "org.team_desc_helper"}}</span> <span class="help">{{ctx.Locale.Tr "org.team_desc_helper"}}</span>
</div> </div>
{{if not (eq .Team.LowerName "owners")}} {{if .Team.IsOwnerTeam}}
<div class="field">
<label>{{ctx.Locale.Tr "org.teams.visibility"}}</label>
<div class="tw-mb-1">
{{if .Team.IsPrivate}}
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
{{else if .Team.IsLimited}}
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
{{else if .Team.IsPublic}}
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
{{end}}
</div>
<span class="help">{{ctx.Locale.Tr "org.teams.owners_visibility_fixed"}}</span>
</div>
{{end}}
{{if not .Team.IsOwnerTeam}}
<div class="grouped field">
<label>{{ctx.Locale.Tr "org.teams.visibility"}}</label>
<br>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="visibility" value="private" {{if or .PageIsOrgTeamsNew .Team.IsPrivate}}checked{{end}}>
<label>{{ctx.Locale.Tr "org.teams.visibility_private"}}</label>
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}</span>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="visibility" value="limited" {{if .Team.IsLimited}}checked{{end}}>
<label>{{ctx.Locale.Tr "org.teams.visibility_limited"}}</label>
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}</span>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="visibility" value="public" {{if .Team.IsPublic}}checked{{end}}>
<label>{{ctx.Locale.Tr "org.teams.visibility_public"}}</label>
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}</span>
</div>
</div>
</div>
<div class="grouped field"> <div class="grouped field">
<label>{{ctx.Locale.Tr "org.team_access_desc"}}</label> <label>{{ctx.Locale.Tr "org.team_access_desc"}}</label>
<br> <br>
@@ -135,7 +175,7 @@
<button class="ui primary button">{{ctx.Locale.Tr "org.create_team"}}</button> <button class="ui primary button">{{ctx.Locale.Tr "org.create_team"}}</button>
{{else}} {{else}}
<button class="ui primary button">{{ctx.Locale.Tr "org.teams.update_settings"}}</button> <button class="ui primary button">{{ctx.Locale.Tr "org.teams.update_settings"}}</button>
{{if not (eq .Team.LowerName "owners")}} {{if not .Team.IsOwnerTeam}}
<button class="ui red button delete-button" data-url="{{.OrgLink}}/teams/{{.Team.Name | PathEscape}}/delete">{{ctx.Locale.Tr "org.teams.delete_team"}}</button> <button class="ui red button delete-button" data-url="{{.OrgLink}}/teams/{{.Team.Name | PathEscape}}/delete">{{ctx.Locale.Tr "org.teams.delete_team"}}</button>
{{end}} {{end}}
{{end}} {{end}}

View File

@@ -1,6 +1,15 @@
<div class="ui six wide column"> <div class="ui six wide column">
<h4 class="ui top attached header flex-left-right"> <h4 class="ui top attached header flex-left-right">
<strong>{{.Team.Name}}</strong> <div class="flex-text-inline">
<strong>{{.Team.Name}}</strong>
{{if .Team.IsPrivate}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
{{else if .Team.IsLimited}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
{{else if .Team.IsPublic}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
{{end}}
</div>
<div class="flex-text-block"> <div class="flex-text-block">
{{if .Team.IsMember ctx $.SignedUser.ID}} {{if .Team.IsMember ctx $.SignedUser.ID}}
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team" <button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
@@ -26,7 +35,7 @@
<div class="ui attached segment"> <div class="ui attached segment">
{{/* TODO: old indent is kept to make diff changes minimal, can be reformatted in the future */}} {{/* TODO: old indent is kept to make diff changes minimal, can be reformatted in the future */}}
{{if eq .Team.LowerName "owners"}} {{if .Team.IsOwnerTeam}}
<p>{{ctx.Locale.Tr "org.teams.owners_permission_desc"}}</p> <p>{{ctx.Locale.Tr "org.teams.owners_permission_desc"}}</p>
<p>{{ctx.Locale.Tr "org.teams.owners_permission_suggestion"}}</p> <p>{{ctx.Locale.Tr "org.teams.owners_permission_suggestion"}}</p>
{{else}} {{else}}

View File

@@ -21,7 +21,16 @@
{{range $team := $.OrgListTeams}} {{range $team := $.OrgListTeams}}
<div class="column team-item-box"> <div class="column team-item-box">
<div class="ui top attached header muted-links flex-left-right team-item-header"> <div class="ui top attached header muted-links flex-left-right team-item-header">
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong>{{.Name}}</strong></a> <div class="flex-text-inline">
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong>{{.Name}}</strong></a>
{{if .IsPrivate}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
{{else if .IsLimited}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
{{else if .IsPublic}}
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
{{end}}
</div>
<div class="flex-text-block tw-flex-wrap"> <div class="flex-text-block tw-flex-wrap">
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}">{{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}}</a> <a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}">{{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}}</a>
· ·

View File

@@ -24992,6 +24992,17 @@
}, },
"x-go-name": "UnitsMap", "x-go-name": "UnitsMap",
"example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}" "example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}"
},
"visibility": {
"description": "Team visibility within the organization. Defaults to \"private\".\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"type": "string",
"enum": [
"public",
"limited",
"private"
],
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"x-go-name": "Visibility"
} }
}, },
"x-go-package": "gitea.dev/modules/structs" "x-go-package": "gitea.dev/modules/structs"
@@ -26190,6 +26201,17 @@
"repo.releases": "none", "repo.releases": "none",
"repo.wiki": "admin" "repo.wiki": "admin"
} }
},
"visibility": {
"description": "Team visibility within the organization. When omitted, visibility is\nleft unchanged.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"type": "string",
"enum": [
"public",
"limited",
"private"
],
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"x-go-name": "Visibility"
} }
}, },
"x-go-package": "gitea.dev/modules/structs" "x-go-package": "gitea.dev/modules/structs"
@@ -30096,6 +30118,17 @@
"repo.releases": "none", "repo.releases": "none",
"repo.wiki": "admin" "repo.wiki": "admin"
} }
},
"visibility": {
"description": "Team visibility within the organization. \"private\" teams are only\nlistable by members and org owners; \"limited\" teams are listable by\nany organization member; \"public\" teams are listable by any signed-in\nuser.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"type": "string",
"enum": [
"public",
"limited",
"private"
],
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
"x-go-name": "Visibility"
} }
}, },
"x-go-package": "gitea.dev/modules/structs" "x-go-package": "gitea.dev/modules/structs"

View File

@@ -4805,6 +4805,14 @@
"example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}", "example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}",
"type": "object", "type": "object",
"x-go-name": "UnitsMap" "x-go-name": "UnitsMap"
},
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/TeamVisibility"
}
],
"description": "Team visibility within the organization. Defaults to \"private\".\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate"
} }
}, },
"required": [ "required": [
@@ -5991,6 +5999,14 @@
}, },
"type": "object", "type": "object",
"x-go-name": "UnitsMap" "x-go-name": "UnitsMap"
},
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/TeamVisibility"
}
],
"description": "Team visibility within the organization. When omitted, visibility is\nleft unchanged.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate"
} }
}, },
"required": [ "required": [
@@ -9938,11 +9954,27 @@
}, },
"type": "object", "type": "object",
"x-go-name": "UnitsMap" "x-go-name": "UnitsMap"
},
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/TeamVisibility"
}
],
"description": "Team visibility within the organization. \"private\" teams are only\nlistable by members and org owners; \"limited\" teams are listable by\nany organization member; \"public\" teams are listable by any signed-in\nuser.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate"
} }
}, },
"type": "object", "type": "object",
"x-go-package": "gitea.dev/modules/structs" "x-go-package": "gitea.dev/modules/structs"
}, },
"TeamVisibility": {
"enum": [
"public",
"limited",
"private"
],
"type": "string"
},
"TimeStamp": { "TimeStamp": {
"description": "TimeStamp defines a timestamp", "description": "TimeStamp defines a timestamp",
"format": "int64", "format": "int64",

View File

@@ -10,12 +10,14 @@ import (
"testing" "testing"
auth_model "gitea.dev/models/auth" auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/organization" "gitea.dev/models/organization"
"gitea.dev/models/perm" "gitea.dev/models/perm"
"gitea.dev/models/repo" "gitea.dev/models/repo"
"gitea.dev/models/unit" "gitea.dev/models/unit"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/services/convert" "gitea.dev/services/convert"
"gitea.dev/tests" "gitea.dev/tests"
@@ -303,3 +305,61 @@ func TestAPIGetTeamRepo(t *testing.T) {
AddTokenAuth(token5) AddTokenAuth(token5)
MakeRequest(t, req, http.StatusNotFound) MakeRequest(t, req, http.StatusNotFound)
} }
func TestAPITeamVisibilityAccess(t *testing.T) {
defer tests.PrepareTestEnv(t)()
insertTestTeam := func(t *testing.T, orgID int64, name string, visibility structs.VisibleType) *organization.Team {
t.Helper()
team := &organization.Team{
OrgID: orgID,
LowerName: name,
Name: name,
AccessMode: perm.AccessModeRead,
Visibility: visibility,
}
assert.NoError(t, db.Insert(t.Context(), team))
return team
}
limitedTeam := insertTestTeam(t, 3, "limited-team", structs.VisibleTypeLimited)
// Org member who can read a limited team must not mutate its repos without membership.
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
token := getUserToken(t, user4.Name, auth_model.AccessTokenScopeWriteOrganization)
req := NewRequestf(t, "PUT", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
publicTeam := insertTestTeam(t, 23, "public-team", structs.VisibleTypePublic)
// Public team in a private org must not be readable by outsiders.
outsider := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token = getUserToken(t, outsider.Name, auth_model.AccessTokenScopeReadOrganization)
req = NewRequestf(t, "GET", "/api/v1/teams/%d", publicTeam.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// Member lookup must require org membership even for public teams.
req = NewRequestf(t, "GET", "/api/v1/teams/%d/members/%s", publicTeam.ID, outsider.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// A limited team's repo list must not leak repos the viewer cannot access.
// repo3 is private; user28 is an org3 member (team12, no repo access) who can
// read the limited team but has no access to repo3.
assert.NoError(t, db.Insert(t.Context(), &organization.TeamRepo{OrgID: 3, TeamID: limitedTeam.ID, RepoID: 3}))
user28 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
token28 := getUserToken(t, user28.Name, auth_model.AccessTokenScopeReadOrganization)
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos", limitedTeam.ID).AddTokenAuth(token28)
resp := MakeRequest(t, req, http.StatusOK)
var repos []*api.Repository
DecodeJSON(t, resp, &repos)
for _, r := range repos {
assert.NotEqual(t, int64(3), r.ID, "must not leak inaccessible private repo3")
}
// The single-repo lookup must not confirm an inaccessible repo's existence.
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).AddTokenAuth(token28)
MakeRequest(t, req, http.StatusNotFound)
}