Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ Definition of an organization `Team`, the following properties are supported:
| _members_ | list[string] | List of users that should be a member of the team | |
| _skip_members_ | boolean | If `true`, team members will be ignored | |
| _skip_non_organization_members_ | boolean | If `true`, users which are not yet organization members can not be added to the team | |
| _team_sync_ | list\[[TeamSync](team-sync.md)\] | List of IdP groups which are connected to the team via GitHub Enterprise Cloud Team Sync | |
| _external_groups_ | string | The id of an external group which is provisioned on the enterprise | Exclusive with `team_sync` |

## Identity provider integration

Teams can be connected to an identity provider either via
GitHub Enterprise Cloud **Team Sync** (`team_sync`) or via
enterprise-wide **external groups** (`external_groups`).

These two mechanisms are **mutually exclusive**.
A team must not define both `team_sync` and `external_groups` at the same time.


## Jsonnet Function
Expand All @@ -24,6 +35,7 @@ orgs.newTeam('<name>') {
- setting `privacy` must be one of `visible` or `secret`, any other value triggers an error
- specifying a non-empty list of `members` while `skip_members` is enabled, triggers an error
- specifying a user in `members` that is not yet an organization member while `skip_non_organization_members` is enabled, triggers an error
- `team_sync` and `external_groups` are mutually exclusive and must not be used together

## Example usage

Expand Down
46 changes: 46 additions & 0 deletions docs/reference/organization/team/team-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Definition of a `TeamSync` for a team, the following properties are supported:

| Key | Value | Description | Note |
|----------------|----------|------------------------------------------------------|----------------------|
| _id_ | string |The unique identifier of the IdP group | |
| _name_ | string |The name of the IdP group (informational, obligatory) | |
| _description_ | string |Human‑readable description of the IdP group | |


## Jsonnet Function

``` jsonnet
orgs.newTeamSync('<name>') {
<key>: <value>
}
```

## Validation rules

- each `TeamSync` entry **must define `id`, `name` and `description`**
- `id`, `name` and `description` **must be non-empty strings**
- omitting any of these fields prevents successful synchronization with GitHub Enterprise Cloud


## Example usage

=== "jsonnet"
``` jsonnet
orgs.newOrg('OtterdogTest') {
...
teams+: [
orgs.newTeam('admins') {
description: "The project admins",
privacy: "secret",

team_sync: [
orgs.newTeamSync('git-admin') {
id: "1234567890",
description: "Admin access via IdP",
},
],
},
],
...
}
```
10 changes: 10 additions & 0 deletions examples/template/otterdog-defaults.libsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,22 @@ local newOrgRole(name) = {
base_role: "none",
};

# Function to create a new team_sync with default settings.
local newTeamSync(name) = {
name: name,
description: "",
id: "",
};

# Function to create a new team with default settings.
local newTeam(name) = {
name: name,
description: "",
privacy: "visible",
notifications: true,
members: [],
team_sync: [],
external_groups: null,
skip_members: false,
skip_non_organization_members: false,
};
Expand Down Expand Up @@ -424,6 +433,7 @@ local newOrg(name, id=name) = {
newOrg:: newOrg,
newOrgRole:: newOrgRole,
newTeam:: newTeam,
newTeamSync:: newTeamSync,
newOrgWebhook:: newOrgWebhook,
newOrgSecret:: newOrgSecret,
newOrgVariable:: newOrgVariable,
Expand Down
4 changes: 3 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ nav:
- Organization Variable: reference/organization/variable.md
- Organization Ruleset: reference/organization/ruleset.md
- Custom Property: reference/organization/custom-property.md
- Team: reference/organization/team.md
- Team:
- reference/organization/team/index.md
- TeamSync: reference/organization/team/team-sync.md
- Repository:
- reference/organization/repository/index.md
- Repository Webhook: reference/organization/repository/webhook.md
Expand Down
11 changes: 11 additions & 0 deletions otterdog/jsonnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class JsonnetConfig:
create_org = "newOrg"
create_org_role = "newOrgRole"
create_org_team = "newTeam"
create_org_team_sync = "newTeamSync"
create_org_custom_property = "newCustomProperty"
create_org_webhook = "newOrgWebhook"
create_org_secret = "newOrgSecret"
Expand Down Expand Up @@ -137,6 +138,16 @@ def default_team_config(self):
_logger.debug("no default team config found, teams will be skipped")
return None

@cached_property
def default_team_sync_config(self):
try:
# load the default team sync config
team_sync_snippet = f"(import '{self.template_file}').{self.create_org_team_sync}('default')"
return jsonnet_evaluate_snippet(team_sync_snippet)
except RuntimeError:
_logger.debug("no default team sync config found, team syncs will be skipped")
return None

@cached_property
def default_org_custom_property_config(self):
try:
Expand Down
11 changes: 10 additions & 1 deletion otterdog/models/github_organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from otterdog.models.repo_workflow_settings import RepositoryWorkflowSettings
from otterdog.models.repository import Repository
from otterdog.models.team import Team
from otterdog.models.team_sync import TeamSync
from otterdog.utils import IndentingPrinter, associate_by_key, debug_times, jsonnet_evaluate_file

if TYPE_CHECKING:
Expand Down Expand Up @@ -583,7 +584,15 @@ async def _load_teams() -> None:
continue
team_members = await provider.get_org_team_members(github_id, team_slug)
team["members"] = team_members
org.add_team(Team.from_provider_data(github_id, team))
# External Groups
external_groups = await provider.get_org_team_external_groups(github_id, team_slug)
team["external_groups"] = external_groups
tm = Team.from_provider_data(github_id, team)
# Do the team-sync
sync_groups = await provider.get_org_team_sync_groups(github_id, team_slug)
for sg in sync_groups:
tm.add_team_sync(TeamSync.from_provider_data(github_id, sg))
org.add_team(tm)
else:
_logger.debug("not reading teams, no default config available")

Expand Down
118 changes: 117 additions & 1 deletion otterdog/models/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,26 @@
from otterdog.models import (
FailureType,
LivePatch,
LivePatchContext,
LivePatchHandler,
LivePatchType,
ModelObject,
PatchContext,
ValidationContext,
)
from otterdog.utils import UNSET, is_set_and_valid, unwrap
from otterdog.models.team_sync import TeamSync
from otterdog.utils import (
UNSET,
Change,
IndentingPrinter,
is_set_and_valid,
unwrap,
write_patch_object_as_json,
)

if TYPE_CHECKING:
from collections.abc import Iterator

from otterdog.jsonnet import JsonnetConfig
from otterdog.providers.github import GitHubProvider

Expand All @@ -43,13 +56,18 @@ class Team(ModelObject, abc.ABC):
privacy: str
notifications: bool
members: list[str]
external_groups: str | None
team_sync: list[TeamSync] = dataclasses.field(metadata={"nested_model": True}, default_factory=list)
skip_members: bool = dataclasses.field(metadata={"model_only": True}, default=False)
skip_non_organization_members: bool = dataclasses.field(metadata={"model_only": True}, default=False)

@property
def model_object_name(self) -> str:
return "team"

def add_team_sync(self, ts: TeamSync) -> None:
self.team_sync.append(ts)

def get_jsonnet_template_function(self, jsonnet_config: JsonnetConfig, extend: bool) -> str | None:
return f"orgs.{jsonnet_config.create_org_team}"

Expand Down Expand Up @@ -98,6 +116,9 @@ def validate(self, context: ValidationContext, parent_object: Any) -> None:
f"but 'members' contains user '{member}' who is not an organization member.",
)

for ts in self.team_sync:
ts.validate(context, self)

@classmethod
def get_mapping_from_provider(cls, org_id: str, data: dict[str, Any]) -> dict[str, Any]:
mapping = super().get_mapping_from_provider(org_id, data)
Expand All @@ -113,15 +134,26 @@ def transform_notification_setting(x: str | None):
def transform_team_members(member):
return member["login"]

def transform_external_groups(value):
if isinstance(value, list) and value:
return value[0]["group_id"]
return None

mapping.update(
{
"privacy": OptionalS("privacy") >> F(lambda x: "visible" if x == "closed" else x),
"notifications": OptionalS("notification_setting") >> F(transform_notification_setting),
"members": OptionalS("members", default=[]) >> Forall(transform_team_members),
"external_groups": OptionalS("external_groups") >> F(transform_external_groups),
}
)
return mapping

def get_model_objects(self) -> Iterator[tuple[ModelObject, ModelObject]]:
for ts in self.team_sync:
yield ts, self
yield from ts.get_model_objects()

@classmethod
async def get_mapping_to_provider(
cls, org_id: str, data: dict[str, Any], provider: GitHubProvider
Expand All @@ -139,6 +171,90 @@ async def get_mapping_to_provider(

return mapping

@classmethod
def get_mapping_from_model(cls) -> dict[str, Any]:
mapping = super().get_mapping_from_model()

mapping.update(
{
"team_sync": OptionalS("team_sync", default=[]) >> Forall(lambda x: TeamSync.from_model_data(x)),
}
)
return mapping

def to_jsonnet(
self,
printer: IndentingPrinter,
jsonnet_config: JsonnetConfig,
context: PatchContext,
extend: bool,
default_object: ModelObject,
) -> None:

has_team_sync = len(self.team_sync) > 0

patch = self.get_patch_to(default_object)

template_function = self.get_jsonnet_template_function(jsonnet_config, False)

printer.print(f"{unwrap(template_function)}('{self.name}')")

write_patch_object_as_json(patch, printer, False)

if has_team_sync and not extend:
default_team_sync = TeamSync.from_model_data(jsonnet_config.default_team_sync_config)
printer.println("team_sync: [")
printer.level_up()

for ts in self.team_sync:
ts.to_jsonnet(printer, jsonnet_config, context, False, default_team_sync)

printer.level_down()
printer.println("],")

# close the team object
printer.level_down()
printer.println("},")

@classmethod
def generate_live_patch(
cls,
expected_object: Team | None,
current_object: Team | None,
parent_object: ModelObject | None,
context: LivePatchContext,
handler: LivePatchHandler,
) -> None:
if expected_object is None:
current_object = unwrap(current_object)
handler(LivePatch.of_deletion(current_object, parent_object, current_object.apply_live_patch))
return

if current_object is None:
handler(LivePatch.of_addition(expected_object, parent_object, expected_object.apply_live_patch))
else:
modified_rule: dict[str, Change[Any]] = expected_object.get_difference_from(current_object)

if len(modified_rule) > 0:
handler(
LivePatch.of_changes(
expected_object,
current_object,
modified_rule,
parent_object,
False,
expected_object.apply_live_patch,
)
)

TeamSync.generate_live_patch_of_list(
expected_object.team_sync,
current_object.team_sync if current_object is not None else [],
expected_object,
context,
handler,
)

@classmethod
async def apply_live_patch(
cls,
Expand Down
Loading