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
23 changes: 23 additions & 0 deletions examples/template/otterdog-defaults.libsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,25 @@ local newOrgRuleset(name) = newRepoRuleset(name) {
protect_repo_names: false,
};

# Function to create a new organization code security configuration with default settings.
local newOrgCodeSecurityConfiguration(name) = {
name: name,
description: "",
advanced_security: "disabled",
dependency_graph: "enabled",
dependency_graph_autosubmit_action: "not_set",
dependabot_alerts: "disabled",
dependabot_security_updates: "disabled",
code_scanning_default_setup: "disabled",
secret_scanning: "disabled",
secret_scanning_push_protection: "disabled",
secret_scanning_delegated_bypass: "disabled",
secret_scanning_validity_checks: "disabled",
secret_scanning_non_provider_patterns: "disabled",
private_vulnerability_reporting: "disabled",
enforcement: "enforced",
};

# Function to create a new organization webhook with default settings.
local newOrgWebhook(url) = {
active: true,
Expand Down Expand Up @@ -408,6 +427,9 @@ local newOrg(name, id=name) = {
# organization rulesets
rulesets: [],

# organization code security configurations
code_security_configurations: [],

# List of repositories of the organization.
# Entries here can be extended during template manifestation:
# * new repos should be defined using the newRepo template
Expand All @@ -428,6 +450,7 @@ local newOrg(name, id=name) = {
newOrgSecret:: newOrgSecret,
newOrgVariable:: newOrgVariable,
newOrgRuleset:: newOrgRuleset,
newOrgCodeSecurityConfiguration:: newOrgCodeSecurityConfiguration,
newCustomProperty:: newCustomProperty,
newRepo:: newRepo,
extendRepo:: extendRepo,
Expand Down
12 changes: 12 additions & 0 deletions otterdog/jsonnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class JsonnetConfig:
create_org_secret = "newOrgSecret"
create_org_variable = "newOrgVariable"
create_org_ruleset = "newOrgRuleset"
create_org_code_security_configuration = "newOrgCodeSecurityConfiguration"
create_repo = "newRepo"
extend_repo = "extendRepo"
create_repo_webhook = "newRepoWebhook"
Expand Down Expand Up @@ -74,6 +75,7 @@ def __init__(
self._default_org_secret_config: dict[str, Any] | None = None
self._default_org_variable_config: dict[str, Any] | None = None
self._default_org_ruleset_config: dict[str, Any] | None = None
self._default_org_code_security_configuration_config: dict[str, Any] | None = None
self._default_repo_config: dict[str, Any] | None = None
self._default_repo_webhook_config: dict[str, Any] | None = None
self._default_repo_secret_config: dict[str, Any] | None = None
Expand Down Expand Up @@ -189,6 +191,16 @@ def default_org_ruleset_config(self):
_logger.debug("no default org ruleset config found, rulesets will be skipped")
return None

@cached_property
def default_org_code_security_configuration_config(self):
try:
# load the default org code security configuration config
snippet = f"(import '{self.template_file}').{self.create_org_code_security_configuration}('default')"
return jsonnet_evaluate_snippet(snippet)
except RuntimeError:
_logger.debug("no default org code security configuration config found, configurations will be skipped")
return None

@cached_property
def default_repo_config(self):
try:
Expand Down
54 changes: 54 additions & 0 deletions otterdog/models/github_organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,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_code_security_configuration import OrganizationCodeSecurityConfiguration
from otterdog.models.organization_role import OrganizationRole
from otterdog.models.organization_ruleset import OrganizationRuleset
from otterdog.models.organization_secret import OrganizationSecret
Expand Down Expand Up @@ -75,6 +76,7 @@ class GitHubOrganization:
secrets: list[OrganizationSecret] = dataclasses.field(default_factory=list)
variables: list[OrganizationVariable] = dataclasses.field(default_factory=list)
rulesets: list[OrganizationRuleset] = dataclasses.field(default_factory=list)
code_security_configurations: list[OrganizationCodeSecurityConfiguration] = dataclasses.field(default_factory=list)
repositories: list[Repository] = dataclasses.field(default_factory=list)

_secrets_resolved: bool = False
Expand Down Expand Up @@ -137,6 +139,15 @@ def get_ruleset(self, name: str) -> OrganizationRuleset | None:
def set_rulesets(self, rulesets: list[OrganizationRuleset]) -> None:
self.rulesets = rulesets

def add_code_security_configuration(self, configuration: OrganizationCodeSecurityConfiguration) -> None:
self.code_security_configurations.append(configuration)

def get_code_security_configuration(self, name: str) -> OrganizationCodeSecurityConfiguration | None:
return next(filter(lambda x: x.name == name, self.code_security_configurations), None) # type: ignore

def set_code_security_configurations(self, configurations: list[OrganizationCodeSecurityConfiguration]) -> None:
self.code_security_configurations = configurations

def add_repository(self, repo: Repository) -> None:
self.repositories.append(repo)

Expand Down Expand Up @@ -205,6 +216,9 @@ async def validate(
for ruleset in self.rulesets:
ruleset.validate(context, self)

for configuration in self.code_security_configurations:
configuration.validate(context, self)

# Run synchronous validations and collect repos needing API codescaning validation
repos_needing_codescaning_language_validation = []
for repo in self.repositories:
Expand Down Expand Up @@ -271,6 +285,10 @@ def get_model_objects(self) -> Iterator[tuple[ModelObject, ModelObject | None]]:
yield ruleset, None
yield from ruleset.get_model_objects()

for configuration in self.code_security_configurations:
yield configuration, None
yield from configuration.get_model_objects()

for repo in self.repositories:
yield repo, None
yield from repo.get_model_objects()
Expand All @@ -291,6 +309,8 @@ def from_model_data(cls, data: dict[str, Any]) -> GitHubOrganization:
"variables": OptionalS("variables", default=[])
>> Forall(lambda x: OrganizationVariable.from_model_data(x)),
"rulesets": OptionalS("rulesets", default=[]) >> Forall(lambda x: OrganizationRuleset.from_model_data(x)),
"code_security_configurations": OptionalS("code_security_configurations", default=[])
>> Forall(lambda x: OrganizationCodeSecurityConfiguration.from_model_data(x)),
"repositories": OptionalS("repositories", default=[]) >> Forall(lambda x: Repository.from_model_data(x)),
}

Expand Down Expand Up @@ -438,6 +458,21 @@ def to_jsonnet(self, config: JsonnetConfig, context: PatchContext) -> str:
printer.level_down()
printer.println("],")

# print organization code security configurations
if len(self.code_security_configurations) > 0:
default_configuration = OrganizationCodeSecurityConfiguration.from_model_data(
config.default_org_code_security_configuration_config
)

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

for configuration in self.code_security_configurations:
configuration.to_jsonnet(printer, config, context, False, default_configuration)

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

# print repositories
if len(self.repositories) > 0:
repos_by_name = associate_by_key(self.repositories, lambda x: x.name)
Expand Down Expand Up @@ -489,6 +524,13 @@ def generate_live_patch(
OrganizationRuleset.generate_live_patch_of_list(
self.rulesets, current_organization.rulesets, None, context, handler
)
OrganizationCodeSecurityConfiguration.generate_live_patch_of_list(
self.code_security_configurations,
current_organization.code_security_configurations,
None,
context,
handler,
)
Repository.generate_live_patch_of_list(
self.repositories, current_organization.repositories, None, context, handler
)
Expand Down Expand Up @@ -637,6 +679,17 @@ async def _load_rulesets() -> None:
else:
_logger.debug("not reading org secrets, no default config available")

@debug_times("code_security_configurations")
async def _load_code_security_configurations() -> None:
if jsonnet_config.default_org_code_security_configuration_config is not None:
github_configurations = await provider.get_org_code_security_configurations(github_id)
for configuration in github_configurations:
org.add_code_security_configuration(
OrganizationCodeSecurityConfiguration.from_provider_data(github_id, configuration)
)
else:
_logger.debug("not reading org code security configurations, no default config available")

@debug_times("repos")
async def _load_repos() -> None:
if jsonnet_config.default_repo_config is not None:
Expand All @@ -660,6 +713,7 @@ async def _load_repos() -> None:
task_group.soonify(_load_secrets)()
task_group.soonify(_load_variables)()
task_group.soonify(_load_rulesets)()
task_group.soonify(_load_code_security_configurations)()
task_group.soonify(_load_repos)()

return org
Expand Down
123 changes: 123 additions & 0 deletions otterdog/models/organization_code_security_configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# *******************************************************************************
# Copyright (c) 2026 Eclipse Foundation and others.
# This program and the accompanying materials are made available
# under the terms of the Eclipse Public License 2.0
# which is available at http://www.eclipse.org/legal/epl-v20.html
# SPDX-License-Identifier: EPL-2.0
# *******************************************************************************

from __future__ import annotations

import dataclasses
from typing import TYPE_CHECKING, Any

from otterdog.models import (
FailureType,
LivePatch,
LivePatchType,
ModelObject,
ValidationContext,
)
from otterdog.utils import is_set_and_valid, unwrap

if TYPE_CHECKING:
from otterdog.jsonnet import JsonnetConfig
from otterdog.providers.github import GitHubProvider


_FEATURE_VALUES = {"enabled", "disabled", "not_set"}
_ENFORCEMENT_VALUES = {"enforced", "unenforced"}


@dataclasses.dataclass
class OrganizationCodeSecurityConfiguration(ModelObject):
"""
Represents a Code Security Configuration defined on organization level.
"""

id: int = dataclasses.field(metadata={"external_only": True})
name: str = dataclasses.field(metadata={"key": True})
description: str
advanced_security: str
dependency_graph: str
dependency_graph_autosubmit_action: str
dependabot_alerts: str
dependabot_security_updates: str
code_scanning_default_setup: str
secret_scanning: str
secret_scanning_push_protection: str
secret_scanning_delegated_bypass: str
secret_scanning_validity_checks: str
secret_scanning_non_provider_patterns: str
private_vulnerability_reporting: str
enforcement: str

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

def validate(self, context: ValidationContext, parent_object: Any) -> None:
feature_fields = (
"advanced_security",
"dependency_graph",
"dependency_graph_autosubmit_action",
"dependabot_alerts",
"dependabot_security_updates",
"code_scanning_default_setup",
"secret_scanning",
"secret_scanning_push_protection",
"secret_scanning_delegated_bypass",
"secret_scanning_validity_checks",
"secret_scanning_non_provider_patterns",
"private_vulnerability_reporting",
)

for field_name in feature_fields:
value = getattr(self, field_name)
if is_set_and_valid(value) and value not in _FEATURE_VALUES:
context.add_failure(
FailureType.ERROR,
f"{self.get_model_header(parent_object)} has '{field_name}' of value '{value}', "
f"while only values ('enabled' | 'disabled' | 'not_set') are allowed.",
)

if is_set_and_valid(self.enforcement) and self.enforcement not in _ENFORCEMENT_VALUES:
context.add_failure(
FailureType.ERROR,
f"{self.get_model_header(parent_object)} has 'enforcement' of value '{self.enforcement}', "
f"while only values ('enforced' | 'unenforced') are allowed.",
)

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

@classmethod
async def apply_live_patch(
cls,
patch: LivePatch[OrganizationCodeSecurityConfiguration],
org_id: str,
provider: GitHubProvider,
) -> None:
match patch.patch_type:
case LivePatchType.ADD:
await provider.add_org_code_security_configuration(
org_id,
await unwrap(patch.expected_object).to_provider_data(org_id, provider),
)

case LivePatchType.REMOVE:
current_object = unwrap(patch.current_object)
await provider.delete_org_code_security_configuration(
org_id,
current_object.id,
current_object.name,
)

case LivePatchType.CHANGE:
current_object = unwrap(patch.current_object)
await provider.update_org_code_security_configuration(
org_id,
current_object.id,
current_object.name,
await unwrap(patch.expected_object).to_provider_data(org_id, provider),
)
29 changes: 29 additions & 0 deletions otterdog/providers/github/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,35 @@ async def update_org_custom_role(self, org_id: str, role_id: int, role_name: str
async def delete_org_custom_role(self, org_id: str, role_id: int, role_name: str) -> None:
await self.rest_api.org.delete_custom_role(org_id, role_id, role_name)

async def get_org_code_security_configurations(self, org_id: str) -> list[dict[str, Any]]:
return await self.rest_api.org.get_code_security_configurations(org_id)

async def add_org_code_security_configuration(self, org_id: str, data: dict[str, Any]) -> None:
await self.rest_api.org.add_code_security_configuration(org_id, data)

async def update_org_code_security_configuration(
self, org_id: str, configuration_id: int, name: str, data: dict[str, Any]
) -> None:
if len(data) > 0:
if configuration_id <= 0:
resolved_id = await self.rest_api.org.get_code_security_configuration_id(org_id, name)
if resolved_id is None:
raise RuntimeError(
f"failed to find code security configuration with name '{name}' in org '{org_id}'"
)
configuration_id = resolved_id

await self.rest_api.org.update_code_security_configuration(org_id, configuration_id, name, data)

async def delete_org_code_security_configuration(self, org_id: str, configuration_id: int, name: str) -> None:
if configuration_id <= 0:
resolved_id = await self.rest_api.org.get_code_security_configuration_id(org_id, name)
if resolved_id is None:
raise RuntimeError(f"failed to find code security configuration with name '{name}' in org '{org_id}'")
configuration_id = resolved_id

await self.rest_api.org.delete_code_security_configuration(org_id, configuration_id, name)

async def get_org_teams(self, org_id: str) -> list[dict[str, Any]]:
return await self.rest_api.team.get_teams(org_id)

Expand Down
Loading