Skip to content
Draft
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
153 changes: 153 additions & 0 deletions docs/reference/policies/project-permissions-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Project Permissions Review

This policy creates periodic GitHub issues to review maintainer and collaborator permissions for repositories and related services.

## Configuration

- `type` - `project_permissions_review`

### Settings

| Setting | Necessity | Value type | Description |
|-------------------|-----------|---------------|----------------------------------------------------------------------------|
| workflow_filter | mandatory | string | Only consider workflow runs that reference the specified workflows |
| issue_title | optional | string | Title for the created GitHub issues (default: "Periodic Review: Project Permissions") |
| artifact_name | optional | string | Name of artifact containing custom issue content (as JSON with "title" and "body" fields) |

## How it works

1. A scheduled workflow runs in a repository (triggered by cron schedule or manually)
2. The workflow calls a reusable workflow matching the `workflow_filter` pattern
3. When the workflow completes successfully, the policy is triggered
4. A new issue is created with the configured content

## Issue Content

The policy can use custom issue content from two sources (checked in this order):

1. **Workflow Artifact** (recommended): If `artifact_name` is configured, the policy will download an artifact containing a `content.json` file with `title` and/or `body` fields
2. **Repository File**: If no artifact is found, it will look for `.github/project-permissions-review-body.md` in your repository

If neither is found, a simple default issue body will be generated with:
- The current year and a basic review prompt
- A note that the issue was automatically generated

## Example

```yaml
name: Project Permissions Review
description: |-
Automatically create annual issues to review repository maintainers,
collaborators, and package registry access.
type: project_permissions_review
config:
workflow_filter: ".*/project-permissions-review.yml.*"
issue_title: "Annual Security Review: Project Permissions"
artifact_name: "project-permissions-review-content"
```

## Reusable Workflow

Create a reusable workflow in your `.otterdog` config repository (e.g., `.otterdog/workflows/.github/workflows/project-permissions-review.yml`):

```yaml
name: Project Permissions Review

on:
workflow_call:
inputs:
issue_title:
description: 'Custom issue title'
required: false
type: string
issue_body:
description: 'Custom issue body in markdown format'
required: false
type: string

jobs:
trigger-review:
runs-on: ubuntu-latest
steps:
- name: Run Project Permissions Review
run: |
echo "Project Permissions Review"

- name: Create content artifact
if: inputs.issue_title != '' || inputs.issue_body != ''
run: |
mkdir -p artifact
cat > artifact/content.json <<'EOF'
{
"title": "${{ inputs.issue_title }}",
"body": ${{ toJSON(inputs.issue_body) }}
}
EOF

- name: Upload content artifact
if: inputs.issue_title != '' || inputs.issue_body != ''
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08
with:
name: project-permissions-review-content
path: artifact/
```

## Usage in Repositories

### Basic Usage (Default Content)

In your repository, create a scheduled workflow (e.g., `.github/workflows/project-review.yml`):

```yaml
name: Project Permissions Review

on:
schedule:
# Run annually on January 1st at 10:00 AM UTC
- cron: '0 10 1 1 *'
workflow_dispatch:

jobs:
review-project-permissions:
uses: <org>/.otterdog/workflows/.github/workflows/project-permissions-review.yml@main
```

Replace `<org>` with your organization name (e.g., `otterdog-kairoaraujo`).

### With Custom Title and Body

To provide custom issue content directly in the workflow:

```yaml
name: Project Permissions Review

on:
schedule:
# Run annually on January 1st at 10:00 AM UTC
- cron: '0 10 1 1 *'
workflow_dispatch:

jobs:
review-project-permissions:
uses: <org>/.otterdog/workflows/.github/workflows/project-permissions-review.yml@main
with:
issue_title: "Annual Security Review: Project Permissions"
issue_body: |
## Project Permissions Review

This is a periodic review to ensure that access permissions for this repository and related services remain appropriate and secure.

### Objectives

1. **Review Current Access**: Verify that all maintainers/commiters and collaborators still require their current level of access
2. **Evaluate Activity**: Check recent contribution activity to ensure accounts are active
3. **Security Audit**: Remove unused or unnecessary permissions


- [ ] Verify all maintainers are still active contributors
- [ ] Confirm admin access is limited to those who need it
- [ ] Remove accounts that are no longer active in the project
- [ ] Review Access to Project publishes packages registries (i.e: PyPI, NPM, Maven, etc)
- [ ] Verify two-factor authentication is enabled for all maintainers
- [ ] Update MAINTAINERS or similar documentation files
```
25 changes: 25 additions & 0 deletions otterdog/providers/github/rest/issue_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
# SPDX-License-Identifier: EPL-2.0
# *******************************************************************************

from typing import Any

from otterdog.logging import get_logger
from otterdog.providers.github.exception import GitHubException

Expand All @@ -18,6 +20,29 @@ class IssueClient(RestClient):
def __init__(self, rest_api: RestApi):
super().__init__(rest_api)

async def create_issue(self, org_id: str, repo_name: str, title: str, body: str) -> dict[str, Any]:
_logger.debug("creating issue '%s' in repo '%s/%s'", title, org_id, repo_name)

try:
data = {"title": title, "body": body}
return await self.requester.request_json("POST", f"/repos/{org_id}/{repo_name}/issues", data=data)
except GitHubException as ex:
raise RuntimeError(f"failed creating issue:\n{ex}") from ex

async def list_issues(
self, org_id: str, repo_name: str, state: str = "open", labels: str | None = None
) -> list[dict[str, Any]]:
_logger.debug("listing issues in repo '%s/%s' with state '%s'", org_id, repo_name, state)

try:
params = {"state": state, "per_page": "100"}
if labels:
params["labels"] = labels

return await self.requester.request_json("GET", f"/repos/{org_id}/{repo_name}/issues", params=params)
except GitHubException as ex:
raise RuntimeError(f"failed listing issues:\n{ex}") from ex

async def create_comment(self, org_id: str, repo_name: str, issue_number: str, body: str) -> None:
_logger.debug("creating issue comment for issue '%s' in repo '%s/%s'", issue_number, org_id, repo_name)

Expand Down
6 changes: 6 additions & 0 deletions otterdog/webapp/policies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
class PolicyType(str, Enum):
DEPENDENCY_TRACK_UPLOAD = "dependency_track_upload"
MACOS_LARGE_RUNNERS_USAGE = "macos_large_runners"
PROJECT_PERMISSIONS_REVIEW = "project_permissions_review"


class Policy(ABC, BaseModel):
Expand Down Expand Up @@ -98,6 +99,11 @@ def create_policy(

return DependencyTrackUploadPolicy.model_validate(data)

case PolicyType.PROJECT_PERMISSIONS_REVIEW:
from otterdog.webapp.policies.project_permissions_review import ProjectPermissionsReviewPolicy

return ProjectPermissionsReviewPolicy.model_validate(data)

case _:
raise RuntimeError(f"unknown policy type '{policy_type}'")

Expand Down
107 changes: 107 additions & 0 deletions otterdog/webapp/policies/project_permissions_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# *******************************************************************************
# Copyright (c) 2024-2025 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
# *******************************************************************************

import re
from datetime import UTC, datetime
from logging import getLogger
from typing import Any, Self

from quart import current_app

from otterdog.utils import expect_type, unwrap
from otterdog.webapp.webhook.github_models import WorkflowRun

from . import Policy, PolicyType

logger = getLogger(__name__)


class ProjectPermissionsReviewPolicy(Policy):
"""
A policy to create periodic GitHub issues for reviewing maintainer and collaborator permissions.
"""

workflow_filter: str
issue_title: str = "Periodic Review: Project Permissions"
artifact_name: str | None = None

@property
def type(self) -> PolicyType:
return PolicyType.PROJECT_PERMISSIONS_REVIEW

def matches_workflow(self, workflow: str) -> bool:
return re.search(self.workflow_filter, workflow) is not None

def merge(self, other: Self) -> Self:
copy = super().merge(other)
copy.workflow_filter = other.workflow_filter
copy.issue_title = other.issue_title
if other.artifact_name is not None:
copy.artifact_name = other.artifact_name
return copy

async def evaluate(
self,
installation_id: int,
github_id: str,
repo_name: str | None = None,
payload: Any | None = None,
) -> None:
repo_name = unwrap(repo_name)
payload = expect_type(payload, WorkflowRun)

if (
payload.conclusion == "success"
and payload.referenced_workflows is not None
and any(self.matches_workflow(x.path) for x in payload.referenced_workflows)
):
from otterdog.webapp.tasks.policies.project_permissions_review import (
ProjectPermissionsReviewTask,
)

logger.info(
f"workflow run {payload.name}/#{payload.id} in repo '{github_id}/{repo_name}' "
f"triggers project permissions review"
)

current_app.add_background_task(
ProjectPermissionsReviewTask(
installation_id,
github_id,
repo_name,
self,
payload.id,
)
)
else:
logger.debug(
f"conditions not met to trigger project permissions review: \n"
f" workflow_filter={self.workflow_filter}, \n"
f" payload.conclusion={payload.conclusion}, \n"
f" payload.referenced_workflows={payload.referenced_workflows}, \n"
f" matches_workflow={any(self.matches_workflow(x.path) for x in payload.referenced_workflows or [])}"
)

def get_issue_body(self, repo_name: str, github_id: str, custom_body: str | None = None) -> str:
"""Generate the issue body for the project permissions review."""
if custom_body:
return custom_body

current_year = datetime.now(UTC).year
return f"""## {current_year} Project Permissions Review

This is a periodic review to ensure that access permissions for this repository and related services remain appropriate and secure.

- [ ] **Review Current Access**: Verify that all maintainers/commiters and collaborators still require their current level of access
- [ ] **Evaluate Activity**: Check recent contribution activity to ensure accounts are active
- [ ] **Security Audit**: Remove unused or unnecessary permissions
- [ ] **Update Documentation**: Ensure that any changes to permissions are documented appropriately
---

*This issue was automatically generated by Otterdog's project permissions review policy.*
"""
Loading