Skip to content

Commit

Permalink
Add support for organization roles and rework security manager setting (
Browse files Browse the repository at this point in the history
#359)

* feat: add support for custom org role

* add documentation, improve validation

* use new role api for security managers, add project_name to GitHubOrganization, update tests and documentation

* update default operations

* chore: improve displaying of squash commit changes

* chore: use installation model for redirection

* chore: revert security_manager_role for now as you must use the predefined role to access repository advisories

* chore: update config if needed

* chore: add changelog entries
  • Loading branch information
netomi authored Dec 20, 2024
1 parent 72f3804 commit d9ad123
Show file tree
Hide file tree
Showing 29 changed files with 572 additions and 95 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,23 @@

### Added

- Added support for organization roles.
- Added operation `check-token-permissions` to list all granted and missing scopes for the cli token.
- Added option to specify reviewers for blueprint type `append_configuration`.
- Added view for currently active remediation PRs for configured blueprints.

### Changed

- Adapted default template for GitHub organizations to take an additional parameter: project_name.
- Changed accessing security managers of an organization using the organization roles api. ([#365](https://github.com/eclipse-csi/otterdog/issues/365))
- Disabled adding automatic help comments for bot users creating a pull request in the config repo.
- Disabled checking of team membership for bot users creating a pull request in the config repo.

### Fixed

- Prevent wrapping of long texts when importing the configuration.
- Fixed displaying changes when settings `squash_merge_commit_title` and `squash_merge_commit_message` were changed at the same time.
- Prevented setting `private_vulnerability_reporting_enabled` for private repositories.
- Prevented wrapping of long texts when importing the configuration.


## [0.9.0] - 09/12/2024
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/organization/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This resource represents a GitHub organization with all supported settings and n

=== "jsonnet"
```jsonnet
orgs.newOrg('<github-id>') {
orgs.newOrg('<project-name>', '<github-id>') {
settings+: { ... }, // (1)!
webhooks+: [ ... ], // (2)!
secrets+: [ ... ], // (3)!
Expand All @@ -30,7 +30,7 @@ This resource represents a GitHub organization with all supported settings and n
## Jsonnet Function

``` jsonnet
orgs.newOrg('<github-id>') {
orgs.newOrg('<project-name>', <github-id>') {
<key>: <value>
}
```
Expand All @@ -43,7 +43,7 @@ The configuration of a GitHub Organization is considered to be valid if all nest

=== "jsonnet"
``` jsonnet
orgs.newOrg('adoptium') {
orgs.newOrg('adoptium', 'adoptium') {
settings+: {
blog: "https://adoptium.net",
default_repository_permission: "none",
Expand Down
46 changes: 46 additions & 0 deletions docs/reference/organization/role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Definition of a custom `Role` on organization level, the following properties are supported:

| Key | Value | Description | Note |
|---------------|----------------|-----------------------------------------------------------|------------------------------------------------|
| _name_ | string | The name of the role | |
| _description_ | string | The description of the role | |
| _permissions_ | list[string] | List of additional permissions | TODO |
| _base_role_ | string | The system role from which this role inherits permissions | `none`, `read`, `write`, `maintain` or `admin` |

## Jsonnet Function

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

## Validation rules

- specifying a non-empty list of `permissions` while `base_role` is set to `none` triggers an error

## Example usage

=== "jsonnet"
``` jsonnet
orgs.newOrg('OtterdogTest') {
...
roles+: [
orgs.newOrgRole('security_team') {
description: "The security team role",
permissions+: [
"delete_alerts_code_scanning",
"org_review_and_manage_secret_scanning_bypass_requests",
"read_code_scanning",
"resolve_dependabot_alerts",
"resolve_secret_scanning_alerts",
"view_dependabot_alerts",
"view_secret_scanning_alerts",
"write_code_scanning",
],
base_role: "read",
},
],
...
}
```
15 changes: 14 additions & 1 deletion examples/template/otterdog-defaults.libsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,14 @@ local newOrgVariable(name) = newRepoVariable(name) {
selected_repositories: [],
};

# Function to create a new organization role with default settings.
local newOrgRole(name) = {
name: name,
description: "",
permissions: [],
base_role: "none",
};

# Function to create a new environment with default settings.
local newEnvironment(name) = {
name: name,
Expand All @@ -262,7 +270,8 @@ local newCustomProperty(name) = {
};

# Function to create a new organization with default settings.
local newOrg(id) = {
local newOrg(name, id=name) = {
project_name: name,
github_id: id,
settings: {
name: null,
Expand Down Expand Up @@ -359,6 +368,9 @@ local newOrg(id) = {
}
},

# organization roles
roles: [],

# organization secrets
secrets: [],

Expand All @@ -385,6 +397,7 @@ local newOrg(id) = {

{
newOrg:: newOrg,
newOrgRole:: newOrgRole,
newOrgWebhook:: newOrgWebhook,
newOrgSecret:: newOrgSecret,
newOrgVariable:: newOrgVariable,
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ nav:
- reference/organization/index.md
- Organization Settings: reference/organization/settings.md
- Organization Workflow Settings: reference/organization/workflow-settings.md
- Organization Role: reference/organization/role.md
- Organization Webhook: reference/organization/webhook.md
- Organization Secret: reference/organization/secret.md
- Organization Variable: reference/organization/variable.md
Expand Down
18 changes: 15 additions & 3 deletions otterdog/jsonnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class JsonnetConfig:
# rather follow a convention to add new resources more easily.

create_org = "newOrg"
create_org_role = "newOrgRole"
create_org_custom_property = "newCustomProperty"
create_org_webhook = "newOrgWebhook"
create_org_secret = "newOrgSecret"
Expand Down Expand Up @@ -66,6 +67,7 @@ def __init__(
self._local_only = local_only

self._default_org_config: dict[str, Any] | None = None
self._default_org_role_config: dict[str, Any] | None = None
self._default_org_custom_property_config: dict[str, Any] | None = None
self._default_org_webhook_config: dict[str, Any] | None = None
self._default_org_secret_config: dict[str, Any] | None = None
Expand Down Expand Up @@ -102,17 +104,27 @@ async def init_template(self) -> None:

self._initialized = True

def default_org_config_for_org_id(self, org_id: str) -> dict[str, Any]:
def default_org_config_for_org_id(self, project_name: str, org_id: str) -> dict[str, Any]:
try:
# load the default settings for the organization
snippet = f"(import '{self.template_file}').{self.create_org}('{org_id}')"
snippet = f"(import '{self.template_file}').{self.create_org}('{project_name}', '{org_id}')"
return jsonnet_evaluate_snippet(snippet)
except RuntimeError as ex:
raise RuntimeError(f"failed to get default organization config for org '{org_id}': {ex}") from ex

@cached_property
def default_org_config(self) -> dict[str, Any]:
return self.default_org_config_for_org_id("default")
return self.default_org_config_for_org_id("default", "default")

@cached_property
def default_org_role_config(self):
try:
# load the default org role config
org_role_snippet = f"(import '{self.template_file}').{self.create_org_role}('default')"
return jsonnet_evaluate_snippet(org_role_snippet)
except RuntimeError:
_logger.debug("no default org role config found, roles will be skipped")
return None

@cached_property
def default_org_custom_property_config(self):
Expand Down
81 changes: 74 additions & 7 deletions otterdog/models/github_organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from otterdog import resources
from otterdog.logging import get_logger
from otterdog.models import (
FailureType,
LivePatchContext,
LivePatchHandler,
ModelObject,
Expand All @@ -31,6 +32,7 @@
from otterdog.models.branch_protection_rule import BranchProtectionRule
from otterdog.models.custom_property import CustomProperty
from otterdog.models.environment import Environment
from otterdog.models.organization_role import OrganizationRole
from otterdog.models.organization_ruleset import OrganizationRuleset
from otterdog.models.organization_secret import OrganizationSecret
from otterdog.models.organization_settings import OrganizationSettings
Expand Down Expand Up @@ -62,8 +64,10 @@ class GitHubOrganization:
Represents a GitHub Organization with its associated resources.
"""

project_name: str
github_id: str
settings: OrganizationSettings
roles: list[OrganizationRole] = dataclasses.field(default_factory=list)
webhooks: list[OrganizationWebhook] = dataclasses.field(default_factory=list)
secrets: list[OrganizationSecret] = dataclasses.field(default_factory=list)
variables: list[OrganizationVariable] = dataclasses.field(default_factory=list)
Expand All @@ -76,6 +80,15 @@ class GitHubOrganization:
def secrets_resolved(self) -> bool:
return self._secrets_resolved

def add_role(self, role: OrganizationRole) -> None:
self.roles.append(role)

def get_role(self, name: str) -> OrganizationRole | None:
return next(filter(lambda x: x.name == name, self.roles), None) # type: ignore

def set_roles(self, roles: list[OrganizationRole]) -> None:
self.roles = roles

def add_webhook(self, webhook: OrganizationWebhook) -> None:
self.webhooks.append(webhook)

Expand Down Expand Up @@ -125,14 +138,33 @@ def validate(self, secret_resolver: SecretResolver, template_dir: str) -> Valida
context = ValidationContext(self, secret_resolver, template_dir)
self.settings.validate(context, self)

enterprise_plan = self.settings.plan == "enterprise"

if len(self.roles) > 0 and not enterprise_plan:
context.add_failure(
FailureType.ERROR,
f"use of organization roles requires an 'enterprise' plan, while this organization is "
f"currently on a '{self.settings.plan}' plan.",
)
else:
for role in self.roles:
role.validate(context, self)

for webhook in self.webhooks:
webhook.validate(context, self)

for secret in self.secrets:
secret.validate(context, self)

for ruleset in self.rulesets:
ruleset.validate(context, self)
if len(self.rulesets) > 0 and not enterprise_plan:
context.add_failure(
FailureType.ERROR,
f"use of organization rulesets requires an 'enterprise' plan, while this organization is "
f"currently on a '{self.settings.plan}' plan.",
)
else:
for ruleset in self.rulesets:
ruleset.validate(context, self)

for repo in self.repositories:
repo.validate(context, self)
Expand Down Expand Up @@ -163,6 +195,10 @@ def get_model_objects(self) -> Iterator[tuple[ModelObject, ModelObject | None]]:
yield self.settings, None
yield from self.settings.get_model_objects()

for role in self.roles:
yield role, None
yield from role.get_model_objects()

for webhook in self.webhooks:
yield webhook, None
yield from webhook.get_model_objects()
Expand All @@ -189,8 +225,10 @@ def from_model_data(cls, data: dict[str, Any]) -> GitHubOrganization:
cls._validate_org_config(data)

mapping = {
"project_name": S("project_name"),
"github_id": S("github_id"),
"settings": S("settings") >> F(lambda x: OrganizationSettings.from_model_data(x)),
"roles": OptionalS("roles", default=[]) >> Forall(lambda x: OrganizationRole.from_model_data(x)),
"webhooks": OptionalS("webhooks", default=[]) >> Forall(lambda x: OrganizationWebhook.from_model_data(x)),
"secrets": OptionalS("secrets", default=[]) >> Forall(lambda x: OrganizationSecret.from_model_data(x)),
"variables": OptionalS("variables", default=[])
Expand Down Expand Up @@ -238,20 +276,35 @@ def unset_settings_requiring_web_ui(self) -> None:
model_object.unset_settings_requiring_web_ui()

def to_jsonnet(self, config: JsonnetConfig, context: PatchContext) -> str:
default_org = GitHubOrganization.from_model_data(config.default_org_config_for_org_id(self.github_id))
default_org = GitHubOrganization.from_model_data(
config.default_org_config_for_org_id(self.project_name, self.github_id)
)

output = StringIO()
printer = IndentingPrinter(output)

printer.println(f"local orgs = {config.import_statement};")
printer.println()
printer.println(f"orgs.{config.create_org}('{self.github_id}') {{")
printer.println(f"orgs.{config.create_org}('{self.project_name}', '{self.github_id}') {{")
printer.level_up()

# print organization settings
printer.print("settings+:")
self.settings.to_jsonnet(printer, config, context, False, default_org.settings)

# print organization roles
if len(self.roles) > 0:
default_org_role = OrganizationRole.from_model_data(config.default_org_role_config)

printer.println("roles+: [")
printer.level_up()

for role in self.roles:
role.to_jsonnet(printer, config, context, False, default_org_role)

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

# print organization webhooks
if len(self.webhooks) > 0:
default_org_webhook = OrganizationWebhook.from_model_data(config.default_org_webhook_config)
Expand Down Expand Up @@ -340,6 +393,7 @@ def to_jsonnet(self, config: JsonnetConfig, context: PatchContext) -> str:
def generate_live_patch(
self, current_organization: GitHubOrganization, context: LivePatchContext, handler: LivePatchHandler
) -> None:
OrganizationRole.generate_live_patch_of_list(self.roles, current_organization.roles, None, context, handler)
OrganizationSettings.generate_live_patch(self.settings, current_organization.settings, None, context, handler)
OrganizationWebhook.generate_live_patch_of_list(
self.webhooks, current_organization.webhooks, None, context, handler
Expand Down Expand Up @@ -382,6 +436,7 @@ def load_from_file(
@classmethod
async def load_from_provider(
cls,
project_name: str,
github_id: str,
jsonnet_config: JsonnetConfig,
provider: GitHubProvider,
Expand Down Expand Up @@ -412,12 +467,24 @@ async def load_from_provider(
CustomProperty.from_provider_data(github_id, x) for x in github_custom_properties
]

org = cls(github_id, settings)
org = cls(project_name, github_id, settings)

start = datetime.now()
_logger.trace("webhooks: reading...")
if jsonnet_config.default_org_role_config is not None and org.settings.plan == "enterprise":
start = datetime.now()
_logger.trace("roles: reading...")
github_roles = await provider.get_org_custom_roles(github_id)

end = datetime.now()
_logger.trace(f"roles: read complete after {(end - start).total_seconds()}s")

for role in github_roles:
org.add_role(OrganizationRole.from_provider_data(github_id, role))
else:
_logger.debug("not reading org webhooks, no default config available")

if jsonnet_config.default_org_webhook_config is not None:
start = datetime.now()
_logger.trace("webhooks: reading...")
github_webhooks = await provider.get_org_webhooks(github_id)

end = datetime.now()
Expand Down
Loading

0 comments on commit d9ad123

Please sign in to comment.