From af8f6ffe770bdff566d8b931000c0f24e95ce039 Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Mon, 27 Jan 2025 16:07:27 +0700 Subject: [PATCH 1/5] Add roles and permissions models and migration --- ctms/models.py | 52 ++++++++ docs/access_controls.md | 114 ++++++++++++++++++ ...2b00e4069aea_add_roles_and_perms_tables.py | 72 +++++++++++ 3 files changed, 238 insertions(+) create mode 100644 docs/access_controls.md create mode 100644 migrations/versions/20250127_2b00e4069aea_add_roles_and_perms_tables.py diff --git a/ctms/models.py b/ctms/models.py index 6d6d7cdb..540368b7 100644 --- a/ctms/models.py +++ b/ctms/models.py @@ -185,6 +185,8 @@ class ApiClient(Base, TimestampMixin): hashed_secret = mapped_column(String, nullable=False) last_access = mapped_column(DateTime(timezone=True)) + # Relationships + roles = relationship("ApiClientRoles", back_populates="api_client") class MozillaFoundationContact(Base, TimestampMixin): __tablename__ = "mofo" @@ -196,3 +198,53 @@ class MozillaFoundationContact(Base, TimestampMixin): mofo_relevant = mapped_column(Boolean) email = relationship("Email", back_populates="mofo", uselist=False) + + +# Permissions models. + + +class Roles(Base): + __tablename__ = "roles" + + id = mapped_column(Integer, primary_key=True) + name = mapped_column(String(255), nullable=False, unique=True) + description = mapped_column(Text, nullable=True) + + # Relationships + permissions = relationship("RolePermissions", back_populates="role") + api_clients = relationship("ApiClientRoles", back_populates="role") + + +class Permissions(Base): + __tablename__ = "permissions" + + id = mapped_column(Integer, primary_key=True) + name = mapped_column(String(255), nullable=False, unique=True) + description = mapped_column(Text, nullable=True) + + # Relationships + roles = relationship("RolePermissions", back_populates="permission") + + +class RolePermissions(Base): + __tablename__ = "role_permissions" + + id = mapped_column(Integer, primary_key=True) + role_id = mapped_column(ForeignKey(Roles.id, ondelete="CASCADE"), nullable=False) + permission_id = mapped_column(ForeignKey(Permissions.id, ondelete="CASCADE"), nullable=False) + + # Relationships + role = relationship("Roles", back_populates="permissions") + permission = relationship("Permissions", back_populates="roles") + + +class ApiClientRoles(Base): + __tablename__ = "api_client_roles" + + id = mapped_column(Integer, primary_key=True) + api_client_id = mapped_column(ForeignKey(ApiClient.client_id, ondelete="CASCADE"), nullable=False) + role_id = mapped_column(ForeignKey(Roles.id, ondelete="CASCADE"), nullable=False) + + # Relationships + api_client = relationship("ApiClient", back_populates="roles") + role = relationship("Roles", back_populates="api_clients") diff --git a/docs/access_controls.md b/docs/access_controls.md new file mode 100644 index 00000000..48daef69 --- /dev/null +++ b/docs/access_controls.md @@ -0,0 +1,114 @@ +# CTMS Access Control Overview + +CTMS uses **Role-Based Access Control (RBAC)** to manage access to resources. The system consists of: +- **Permissions**: Define specific actions that can be performed. +- **Roles**: Group permissions together for easier management. +- **API Clients**: The oAuth clients that authenticate and receive roles. + +--- + +## Permissions + +**Permissions** define **what actions can be performed** within the system. They are the +**lowest level of access control** and must be assigned to **roles**. + +### Key Characteristics +- Permissions are not assigned directly to API clients. +- Roles are the only way permissions are granted to clients. +- A single permission can be used in multiple roles. + +### Example Permissions +| Permission Name | Description | +|----------------|-------------| +| `manage_contacts` | Grants the ability to create, edit, and delete contacts | +| `view_updates` | Allows access to updated contacts | + +--- + +## Roles + +**Roles** are collections of **permissions**. Assigning a role to an API client grants them all the +permissions within that role. + +### Key Characteristics +- Roles group multiple permissions together. +- API clients receive roles, not individual permissions. +- A single API client can have multiple roles. + +### Example Roles +| Role Name | Assigned Permissions | +|-----------|----------------------| +| `admin` | `manage_contacts`, `view_updates` | +| `viewer` | `view_updates` | + +--- + +## API Clients + +**API Clients** represent oAuth clients that authenticate and receive **roles**. + +### Key Characteristics +- Authenticate using oAuth2 client credentials. +- Receive access via assigned roles. +- Can be enabled or disabled as needed. +- Secrets can be rotated for security. + +--- + +## Authentication: Obtaining an OAuth Token + +API clients authenticate using the **OAuth2 Client Credentials Flow**. + +After **creating a client**, use the following `curl` request to obtain an access token: + +```sh +curl --user : -F grant_type=client_credentials /token +``` + +The JSON response will have an access token, such as: + +```json +{ + "access_token": "", + "token_type": "bearer", + "expires_in": +} +``` + +This can be used to access the API, such as: + +```sh +curl --oauth2-bearer /ctms?primary_email= +``` + +## Protecting Endpoints in FastAPI + +To protect an API endpoint and **require a specific permission**, use the **`with_permission`** +helper from `ctms.permissions`. + +### Usage + +Using `"delete_contact"` as the example **permission**. + +1. Import `with_permission` in your FastAPI app: + ```python + from ctms.permissions import with_permission + from fastapi import Depends, APIRouter + from typing import Annotated + ``` + +2. Protect an API route by requiring a permission (made up example): + ```python + router = APIRouter() + + @router.delete("/contacts/{contact_id}") + def delete_contact( + contact_id: int, + db: Annotated[Session, Depends(get_db)], + _: Annotated[bool, Depends(with_permission("delete_contact"))], + ): + return {"message": f"Contact {contact_id} deleted"} + ``` + +- The `with_permission("delete_contact")` ensures that the client making the request has the `delete_contact` permission. +- If the client does not have the required permission, FastAPI returns a 403 Forbidden response. diff --git a/migrations/versions/20250127_2b00e4069aea_add_roles_and_perms_tables.py b/migrations/versions/20250127_2b00e4069aea_add_roles_and_perms_tables.py new file mode 100644 index 00000000..c696d8d3 --- /dev/null +++ b/migrations/versions/20250127_2b00e4069aea_add_roles_and_perms_tables.py @@ -0,0 +1,72 @@ +"""Add roles and perms tables + +Revision ID: 2b00e4069aea +Revises: fd04e56b249e +Create Date: 2025-01-27 08:47:33.355262 + +""" +# pylint: disable=no-member invalid-name +# no-member is triggered by alembic.op, which has dynamically added functions +# invalid-name is triggered by migration file names with a date prefix +# invalid-name is triggered by top-level alembic constants like revision instead of REVISION + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2b00e4069aea" # pragma: allowlist secret +down_revision = "fd04e56b249e" # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "permissions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "roles", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "api_client_roles", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("api_client_id", sa.String(length=255), nullable=False), + sa.Column("role_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["api_client_id"], ["api_client.client_id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "role_permissions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("role_id", sa.Integer(), nullable=False), + sa.Column("permission_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["permission_id"], ["permissions.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("role_permissions") + op.drop_table("api_client_roles") + op.drop_table("roles") + op.drop_table("permissions") + # ### end Alembic commands ### From cdf414d6e67d76ded0c11bb5ed615a0f20b1137b Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Mon, 10 Feb 2025 10:46:20 +0700 Subject: [PATCH 2/5] Add permission helpers and fastapi dependency --- Makefile | 2 +- ctms/crud.py | 3 +- ctms/models.py | 1 + ctms/permissions.py | 98 ++++++++++++++++++++++++++++++++++ tests/factories/models.py | 44 +++++++++++++++ tests/helpers.py | 13 +++++ tests/unit/conftest.py | 70 ++++++++++++++++++++++-- tests/unit/test_permissions.py | 46 ++++++++++++++++ 8 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 ctms/permissions.py create mode 100644 tests/helpers.py create mode 100644 tests/unit/test_permissions.py diff --git a/Makefile b/Makefile index 5504b06b..bd58e4a4 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ start: .env .PHONY: test test: .env $(INSTALL_STAMP) - COVERAGE_REPORT=1 bin/test.sh + COVERAGE_REPORT=1 bin/test.sh $(filter-out test, $(MAKECMDGOALS)) .PHONY: integration-test integration-test: .env setup $(INSTALL_STAMP) diff --git a/ctms/crud.py b/ctms/crud.py index 3677477d..3281be74 100644 --- a/ctms/crud.py +++ b/ctms/crud.py @@ -62,8 +62,7 @@ def count_total_contacts(db: Session) -> int: If the catalog estimate is unavailable, we verify if there is at least one email in the table and return 1 to indicate its presence. """ - result = db.execute(text(f"SELECT reltuples AS estimate FROM pg_class WHERE relname = '{Email.__tablename__}'")).scalar() - + result = db.execute(text(f"SELECT reltuples AS estimate FROM pg_class where relname = '{Email.__tablename__}'")).scalar() if result is None or result < 0: # Fall back to verifying if there's at least one record. result = db.execute(text(f"SELECT 1 FROM {Email.__tablename__} LIMIT 1")).scalar() diff --git a/ctms/models.py b/ctms/models.py index 540368b7..e3d7ce81 100644 --- a/ctms/models.py +++ b/ctms/models.py @@ -188,6 +188,7 @@ class ApiClient(Base, TimestampMixin): # Relationships roles = relationship("ApiClientRoles", back_populates="api_client") + class MozillaFoundationContact(Base, TimestampMixin): __tablename__ = "mofo" diff --git a/ctms/permissions.py b/ctms/permissions.py new file mode 100644 index 00000000..07a492c0 --- /dev/null +++ b/ctms/permissions.py @@ -0,0 +1,98 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy.sql import exists + +from ctms.dependencies import get_db, get_enabled_api_client +from ctms.models import ApiClientRoles, Permissions, RolePermissions, Roles +from ctms.schemas import ApiClientSchema + +ADMIN_ROLE_NAME = "admin" # Define the admin role name globally + + +def has_permission(db: Session, api_client_id: str, permission_name: str) -> bool: + """ + Check if an api_client has a specific permission. + + Args: + session (Session): SQLAlchemy database session. + api_client_id (str): The client_id of the api_client to check. + permission_name (str): The name of the permission to check (e.g. 'delete_contact'). + + Returns: + bool: True if the api_client has the specified permission, False otherwise. + """ + perm = db.query( + exists() + .where(Permissions.name == permission_name) + .where(Permissions.id == RolePermissions.permission_id) + .where(RolePermissions.role_id == ApiClientRoles.role_id) + .where(ApiClientRoles.api_client_id == api_client_id) + ).scalar() + return bool(perm) + + +def has_any_permission(db: Session, api_client_id: str, permission_names: list[str]) -> bool: + """ + Check if an api_client has at least one of the specified permissions, or is an admin. + + Args: + db (Session): SQLAlchemy database session. + api_client_id (str): The client_id of the api_client. + permission_names (list[str]): A list of permission names to check. + + Returns: + bool: True if the api_client has at least one of the specified permissions or is an admin. + + """ + # First, check if the api_client has the admin role, which has all permissions. + is_admin = db.query( + exists() + .where(Roles.name == ADMIN_ROLE_NAME) + .where(Roles.id == ApiClientRoles.role_id) + .where(ApiClientRoles.api_client_id == api_client_id) + ).scalar() # fmt: skip + + if is_admin: + return True + + # Check if the user has at least one of the requested permissions + has_perm = db.query( + exists() + .where(Permissions.name.in_(permission_names)) + .where(Permissions.id == RolePermissions.permission_id) + .where(RolePermissions.role_id == ApiClientRoles.role_id) + .where(ApiClientRoles.api_client_id == api_client_id) + ).scalar() + + if has_perm: + return True + + return False + + +def with_permission(*permission_names: str): + """ + FastAPI dependency that checks if the api_client has at least one of the specified permissions, + or has the admin role. + + Args: + *permission_names (str): The permissions required. + + Returns: + FastAPI dependency function. + """ + + def dependency( + db: Annotated[Session, Depends(get_db)], + api_client: Annotated[ApiClientSchema, Depends(get_enabled_api_client)], + ): + if not has_any_permission(db, api_client.client_id, list(permission_names)): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission(s) required: {', '.join(permission_names)}", + ) + return True + + return dependency diff --git a/tests/factories/models.py b/tests/factories/models.py index d69a6e08..a950ecc8 100644 --- a/tests/factories/models.py +++ b/tests/factories/models.py @@ -153,9 +153,53 @@ class Params: ) +class ApiClientFactory(BaseSQLAlchemyModelFactory): + class Meta: + model = models.ApiClient + + client_id = factory.Sequence(lambda n: f"client-id-{n}") + email = factory.Sequence(lambda n: f"email-{n}@example.com") + hashed_secret = factory.Sequence(lambda n: f"secret-{n}") + + +class RoleFactory(BaseSQLAlchemyModelFactory): + class Meta: + model = models.Roles + + name = factory.Faker("word") + + +class PermissionFactory(BaseSQLAlchemyModelFactory): + class Meta: + model = models.Permissions + + name = factory.Faker("word") + + +class ApiClientRolesFactory(BaseSQLAlchemyModelFactory): + class Meta: + model = models.ApiClientRoles + + api_client = factory.SubFactory(ApiClientFactory) + role = factory.SubFactory(RoleFactory) + + +class RolePermissionsFactory(BaseSQLAlchemyModelFactory): + class Meta: + model = models.RolePermissions + + role = factory.SubFactory(RoleFactory) + permission = factory.SubFactory(PermissionFactory) + + __all__ = ( + "ApiClientFactory", + "ApiClientRolesFactory", "EmailFactory", "FirefoxAccountFactory", "NewsletterFactory", + "PermissionFactory", + "RoleFactory", + "RolePermissionsFactory", "WaitlistFactory", ) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..75fbdf7b --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,13 @@ +from ctms import models + + +def assign_role(db, api_client_id, role_name): + role = db.query(models.Roles).filter_by(name=role_name).first() + if not role: + role = models.Roles(name=role_name) + db.add(role) + db.commit() + + api_client_role = models.ApiClientRoles(api_client_id=api_client_id, role_id=role.id) + db.add(api_client_role) + db.commit() diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index c88c2b82..90324322 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -19,7 +19,7 @@ from sqlalchemy_utils.functions import create_database, database_exists, drop_database from ctms import metrics as metrics_module -from ctms import schemas +from ctms import models, schemas from ctms.app import app from ctms.config import Settings from ctms.crud import ( @@ -36,9 +36,11 @@ from ctms.database import ScopedSessionLocal, SessionLocal from ctms.dependencies import get_api_client, get_db from ctms.metrics import get_metrics +from ctms.permissions import ADMIN_ROLE_NAME from ctms.schemas import ApiClientSchema, ContactSchema from ctms.schemas.contact import ContactInSchema from tests import factories +from tests.helpers import assign_role MY_FOLDER = os.path.dirname(__file__) TEST_FOLDER = os.path.dirname(MY_FOLDER) @@ -132,6 +134,14 @@ def connection(engine): conn.close() +@pytest.fixture(scope="session") +def session_dbsession(connection): + """Provides a session that lasts the entire pytest session (avoiding transaction rollbacks).""" + session = SessionLocal() + yield session + session.close() + + @pytest.fixture(autouse=True) def dbsession(request, connection): """Return a database session that rolls back. @@ -148,9 +158,22 @@ def dbsession(request, connection): transaction.rollback() +@pytest.fixture(scope="session", autouse=True) +def setup_admin_role(session_dbsession): + """Ensures the admin role exists before running tests.""" + if not session_dbsession.query(models.Roles).filter_by(name=ADMIN_ROLE_NAME).first(): + session_dbsession.add(models.Roles(name=ADMIN_ROLE_NAME)) + session_dbsession.commit() + + # Database models +register(factories.models.ApiClientFactory) +register(factories.models.ApiClientRolesFactory) register(factories.models.EmailFactory) register(factories.models.NewsletterFactory) +register(factories.models.PermissionFactory) +register(factories.models.RoleFactory) +register(factories.models.RolePermissionsFactory) register(factories.models.WaitlistFactory) @@ -464,11 +487,50 @@ def override_get_db(): @pytest.fixture -def client(anon_client): - """A test client that passed a valid OAuth2 token.""" +def restricted_client(anon_client, dbsession): + """A test client with valid authorization but no special permissions.""" + + # Create an API client that we associate with the restricted test client. + api_client = models.ApiClient( + client_id="restricted_client", + email="restricted_client@example.com", + hashed_secret="test_secret", # pragma: allowlist secret + enabled=True, + ) + dbsession.add(api_client) + dbsession.commit() + + def test_api_client(): + return ApiClientSchema(client_id=api_client.client_id, email=api_client.email, enabled=True) + + app.dependency_overrides[get_api_client] = test_api_client + yield anon_client + del app.dependency_overrides[get_api_client] + + +@pytest.fixture +def client(anon_client, dbsession): + """A test client that passed a valid OAuth2 token and has the admin role.""" + + # Create an API client that we associate with the test client. + api_client = models.ApiClient( + client_id="test_client", + email="test_client@example.com", + hashed_secret="test_secret", # pragma: allowlist secret + enabled=True, + ) + dbsession.add(api_client) + + # Save the schema here to avoid a detached instance error below. + client_schema = ApiClientSchema(client_id=api_client.client_id, email=api_client.email, enabled=True) + + dbsession.commit() + + # Assign the admin role to the API client + assign_role(dbsession, api_client.client_id, ADMIN_ROLE_NAME) def test_api_client(): - return ApiClientSchema(client_id="test_client", email="test_client@example.com", enabled=True) + return client_schema app.dependency_overrides[get_api_client] = test_api_client yield anon_client diff --git a/tests/unit/test_permissions.py b/tests/unit/test_permissions.py new file mode 100644 index 00000000..0b29140f --- /dev/null +++ b/tests/unit/test_permissions.py @@ -0,0 +1,46 @@ +from ctms import models +from ctms.permissions import ADMIN_ROLE_NAME, has_any_permission + + +def test_has_permission_granted( + dbsession, + api_client_factory, + api_client_roles_factory, + permission_factory, + role_factory, + role_permissions_factory, +): + """Test that a user with the required permission is granted access.""" + role = role_factory(name="editor") + permission = permission_factory(name="edit_contact") + role_permissions_factory(role=role, permission=permission) + + api_client = api_client_factory() + api_client_roles_factory(api_client=api_client, role=role) + + dbsession.commit() + + assert has_any_permission(dbsession, api_client.client_id, ["edit_contact"]) is True + + +def test_has_permission_denied(dbsession, api_client_factory): + """Test that a user without the required permission is denied access.""" + api_client = api_client_factory() + dbsession.commit() + + assert has_any_permission(dbsession, api_client.client_id, ["edit_contact"]) is False + + +def test_admin_role_grants_all_permissions( + dbsession, + api_client_factory, + api_client_roles_factory, + role_factory, +): + """Test that a user with the admin role is granted access to all actions.""" + admin_role = dbsession.query(models.Roles).filter_by(name=ADMIN_ROLE_NAME).first() + api_client = api_client_factory() + api_client_roles_factory(api_client=api_client, role=admin_role) + dbsession.commit() + + assert has_any_permission(dbsession, api_client.client_id, ["any_perm", "other_perm"]) is True From 42dbf6b95a53bbe6a9a0ccc8c149e6731ee7b28d Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Mon, 10 Feb 2025 11:12:15 +0700 Subject: [PATCH 3/5] Add CTMS CLI to manage permissions, roles, and clients --- Dockerfile | 8 +- Makefile | 1 + ctms/cli/__init__.py | 0 ctms/cli/clients.py | 299 +++++++++++++++++++ ctms/cli/main.py | 22 ++ ctms/cli/permissions.py | 82 +++++ ctms/cli/roles.py | 183 ++++++++++++ ctms/crud.py | 5 + ctms/models.py | 10 +- docs/README.md | 1 + docs/access_controls.md | 4 +- docs/cli.md | 162 ++++++++++ docs/developer_setup.md | 13 +- poetry.lock | 2 +- pyproject.toml | 9 +- tests/unit/cli/__init__.py | 0 tests/unit/cli/conftest.py | 8 + tests/unit/cli/test_clients.py | 461 +++++++++++++++++++++++++++++ tests/unit/cli/test_permissions.py | 143 +++++++++ tests/unit/cli/test_roles.py | 416 ++++++++++++++++++++++++++ 20 files changed, 1810 insertions(+), 19 deletions(-) create mode 100644 ctms/cli/__init__.py create mode 100644 ctms/cli/clients.py create mode 100644 ctms/cli/main.py create mode 100644 ctms/cli/permissions.py create mode 100644 ctms/cli/roles.py create mode 100644 docs/cli.md create mode 100644 tests/unit/cli/__init__.py create mode 100644 tests/unit/cli/conftest.py create mode 100644 tests/unit/cli/test_clients.py create mode 100644 tests/unit/cli/test_permissions.py create mode 100644 tests/unit/cli/test_roles.py diff --git a/Dockerfile b/Dockerfile index 06bfbe40..81453c68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.12.7 AS python-base ENV PIP_DEFAULT_TIMEOUT=100 \ PIP_DISABLE_PIP_VERSION_CHECK=on \ PIP_NO_CACHE_DIR=off \ - POETRY_HOME=/opt/poetry\ + POETRY_HOME=/opt/poetry \ POETRY_NO_INTERACTION=1 \ POETRY_VIRTUALENVS_IN_PROJECT=true \ PYSETUP_PATH="/opt/pysetup" @@ -13,8 +13,10 @@ RUN python3 -m venv $POETRY_HOME && \ $POETRY_HOME/bin/poetry --version WORKDIR $PYSETUP_PATH -COPY poetry.lock pyproject.toml . -RUN $POETRY_HOME/bin/poetry install --no-root --only main +COPY poetry.lock pyproject.toml README.md . +# Copy ctms folder for ctms-cli installation. +COPY ctms /opt/pysetup/ctms/ +RUN $POETRY_HOME/bin/poetry install --only main FROM python:3.12.7-slim AS production diff --git a/Makefile b/Makefile index bd58e4a4..0163eb02 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,7 @@ start: .env .PHONY: test test: .env $(INSTALL_STAMP) COVERAGE_REPORT=1 bin/test.sh $(filter-out test, $(MAKECMDGOALS)) + coverage report -m .PHONY: integration-test integration-test: .env setup $(INSTALL_STAMP) diff --git a/ctms/cli/__init__.py b/ctms/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ctms/cli/clients.py b/ctms/cli/clients.py new file mode 100644 index 00000000..39de9131 --- /dev/null +++ b/ctms/cli/clients.py @@ -0,0 +1,299 @@ +import re +import sys +from secrets import token_urlsafe + +import click +from pydantic import EmailStr +from pydantic_core import PydanticCustomError +from sqlalchemy.orm import Session + +from ctms import config +from ctms.crud import create_api_client, update_api_client_secret +from ctms.models import ApiClient, ApiClientRoles, Roles +from ctms.schemas import ApiClientSchema + + +def validate_client_id(client_id: str) -> bool: + """ + Validate that a client ID follows the required format. + + Requirements: + - Must start with 'id_' + - Must only contain alphanumeric characters, hyphens, underscores, or periods + + Returns: + bool: True if valid, False otherwise + """ + return client_id.startswith("id_") and re.match(r"^[-_.a-zA-Z0-9]*$", client_id) is not None + + +class ClientIdParamType(click.ParamType): + name = "client_id" + + def convert(self, value, param, ctx): + if not validate_client_id(value): + self.fail( + f"Client ID '{value}' is invalid. It must start with 'id_' and should contain only " + "alphanumeric characters, hyphens, underscores, or periods.", + param, + ctx, + ) + return value + + +class EmailParamType(click.ParamType): + name = "email" + + def convert(self, value, param, ctx): + try: + return EmailStr._validate(value) + except PydanticCustomError as e: + self.fail(e, param, ctx) + + +@click.group() +@click.pass_context +def clients_cli(ctx: click.Context) -> None: + """Manage API clients and their roles.""" + ctx.ensure_object(dict) + + +@clients_cli.command("list") +@click.pass_context +def list_clients(ctx: click.Context) -> None: + """List all API clients, showing last accessed datetime for enabled clients.""" + db: Session = ctx.obj["db"] + clients: list[ApiClient] = db.query(ApiClient).all() + + if not clients: + click.echo("No API clients found.") + return + + click.echo("API Clients:") + for client in clients: + status: str = "Enabled" if client.enabled else "Disabled" + last_access_display: str = client.last_access.strftime("%Y-%m-%d %H:%M:%S") if client.enabled and client.last_access else "Never" + + if client.enabled: + click.echo(f"- {client.client_id} ({client.email}) - {status} | Last Access: {last_access_display}") + else: + click.echo(f"- {client.client_id} ({client.email}) - {status}") + + +@clients_cli.command("show") +@click.argument("client_id", type=ClientIdParamType()) +@click.pass_context +def show_client(ctx: click.Context, client_id: str) -> None: + """Show details of an API client.""" + db: Session = ctx.obj["db"] + + client: ApiClient | None = db.query(ApiClient).filter(ApiClient.client_id == client_id).first() + if not client: + click.echo(f"API client '{client_id}' not found.") + sys.exit(4) + + click.echo(f"API Client: {client.client_id}") + click.echo(f"Email: {client.email}") + click.echo(f"Enabled: {'Yes' if client.enabled else 'No'}") + click.echo(f"Last Access: {client.last_access or 'Never'}") + + if client.roles: + click.echo("Assigned Roles:") + for client_role in client.roles: + click.echo(f" - {client_role.role.name}") + else: + click.echo("No roles assigned.") + + +@clients_cli.command("grant") +@click.argument("client_id", type=ClientIdParamType()) +@click.argument("role_name", type=str) +@click.pass_context +def grant_role(ctx: click.Context, client_id: str, role_name: str) -> None: + """Grant a role to an API client.""" + db: Session = ctx.obj["db"] + + client: ApiClient | None = db.query(ApiClient).filter(ApiClient.client_id == client_id).first() + if not client: + click.echo(f"API client '{client_id}' not found.") + sys.exit(4) + + role: Roles | None = db.query(Roles).filter(Roles.name == role_name).first() + if not role: + click.echo(f"Role '{role_name}' not found.") + sys.exit(4) + + if any(client_role.role_id == role.id for client_role in client.roles): + click.echo(f"API client '{client_id}' already has role '{role_name}'.") + return + + new_assignment: ApiClientRoles = ApiClientRoles(api_client_id=client_id, role_id=role.id) + db.add(new_assignment) + db.commit() + + click.echo(f"✅ Granted role '{role_name}' to API client '{client_id}'.") + + +@clients_cli.command("revoke") +@click.argument("client_id", type=ClientIdParamType()) +@click.argument("role_name", type=str) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.") +@click.pass_context +def remove_role(ctx: click.Context, client_id: str, role_name: str, yes: bool) -> None: + """Revoke a role from an API client.""" + db: Session = ctx.obj["db"] + + client: ApiClient | None = db.query(ApiClient).filter(ApiClient.client_id == client_id).first() + if not client: + click.echo(f"API client '{client_id}' not found.") + sys.exit(4) + + role: Roles | None = db.query(Roles).filter(Roles.name == role_name).first() + if not role: + click.echo(f"Role '{role_name}' not found.") + sys.exit(4) + + role_assignment = next((cr for cr in client.roles if cr.role_id == role.id), None) + + if not role_assignment: + click.echo(f"API client '{client_id}' does not have role '{role_name}'.") + sys.exit(10) + + if not yes and not click.confirm(f"Are you sure you want to revoke role '{role_name}' from API client '{client_id}'?"): + click.echo("Operation cancelled.") + return + + db.delete(role_assignment) + db.commit() + + click.echo(f"✅ Revoked role '{role_name}' from API client '{client_id}'.") + + +@clients_cli.command("create") +@click.argument("client_id", type=ClientIdParamType()) +@click.argument("email", type=EmailParamType()) +@click.pass_context +def create_client(ctx: click.Context, client_id: str, email: str) -> None: + """Create a new API client and output credentials.""" + db: Session = ctx.obj["db"] + + # Check if client already exists. + existing_client: ApiClient | None = db.query(ApiClient).filter(ApiClient.client_id == client_id).first() + if existing_client: + click.echo(f"API client '{client_id}' already exists.") + sys.exit(10) + + api_client: ApiClientSchema = ApiClientSchema(client_id=client_id, email=email, enabled=True) + client_secret: str = f"secret_{token_urlsafe(32)}" + create_api_client(db, api_client, client_secret) + db.commit() + + click.echo(f"✅ Created API client '{client_id}' with email: '{email}'.") + output_client_credentials(client_id, client_secret) + + +@clients_cli.command("delete") +@click.argument("client_id", type=ClientIdParamType()) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.") +@click.pass_context +def delete_client(ctx: click.Context, client_id: str, yes: bool) -> None: + """Delete an API client.""" + db: Session = ctx.obj["db"] + + client: ApiClient | None = db.query(ApiClient).filter(ApiClient.client_id == client_id).first() + if not client: + click.echo(f"API client '{client_id}' not found.") + sys.exit(4) + + if not yes and not click.confirm(f"Are you sure you want to delete API client '{client_id}'?"): + click.echo("Operation cancelled.") + return + + db.delete(client) + db.commit() + + click.echo(f"✅ Deleted API client '{client_id}'.") + + +@clients_cli.command("enable") +@click.argument("client_id", type=ClientIdParamType()) +@click.pass_context +def enable_client(ctx: click.Context, client_id: str) -> None: + """Enable an API client.""" + db: Session = ctx.obj["db"] + + client: ApiClient | None = db.query(ApiClient).filter(ApiClient.client_id == client_id).first() + if not client: + click.echo(f"API client '{client_id}' not found.") + sys.exit(4) + + if not client.enabled: + client.enabled = True + db.commit() + click.echo(f"✅ Enabled API client '{client_id}'.") + else: + click.echo(f"API client '{client_id}' is already enabled.") + + +@clients_cli.command("disable") +@click.argument("client_id", type=ClientIdParamType()) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.") +@click.pass_context +def disable_client(ctx: click.Context, client_id: str, yes: bool) -> None: + """Disable an API client.""" + db: Session = ctx.obj["db"] + + client: ApiClient | None = db.query(ApiClient).filter(ApiClient.client_id == client_id).first() + if not client: + click.echo(f"API client '{client_id}' not found.") + sys.exit(4) + + if client.enabled: + if not yes and not click.confirm(f"Are you sure you want to disable API client '{client_id}'? This will prevent it from accessing the API."): + click.echo("Operation cancelled.") + return + + client.enabled = False + db.commit() + click.echo(f"✅ Disabled API client '{client_id}'.") + else: + click.echo(f"API client '{client_id}' is already disabled.") + + +@clients_cli.command("rotate-secret") +@click.argument("client_id", type=ClientIdParamType()) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.") +@click.pass_context +def rotate_secret(ctx: click.Context, client_id: str, yes: bool) -> None: + """Rotate the secret for an existing API client and output new credentials.""" + db: Session = ctx.obj["db"] + + api_client: ApiClient | None = db.query(ApiClient).filter(ApiClient.client_id == client_id).first() + if not api_client: + click.echo(f"API client '{client_id}' not found.") + sys.exit(4) + + if not yes and not click.confirm( + f"Are you sure you want to rotate the secret for API client '{client_id}'? This will invalidate the existing secret." + ): + click.echo("Operation cancelled.") + return + + client_secret: str = f"secret_{token_urlsafe(32)}" + update_api_client_secret(db, api_client, client_secret) + db.commit() + + click.echo(f"✅ Rotated secret for API client '{client_id}'.") + output_client_credentials(client_id, client_secret) + + +def output_client_credentials(client_id: str, client_secret: str) -> None: + """Output the client credentials to the console.""" + settings = config.Settings() + + click.echo("\n** 🔑 Client Credentials -- Store Securely! 🔑 **") + click.echo(f" - Client ID: {client_id}") + click.echo(f" - Client Secret: {client_secret}") + click.echo("\n** Example: Obtain an OAuth Token **") + click.echo("Use the following curl command to get an access token:") + click.echo(f"\n curl --user {client_id}:{client_secret} -F grant_type=client_credentials {settings.server_prefix}/token\n") diff --git a/ctms/cli/main.py b/ctms/cli/main.py new file mode 100644 index 00000000..1ff33db2 --- /dev/null +++ b/ctms/cli/main.py @@ -0,0 +1,22 @@ +import click + +from ctms.cli.clients import clients_cli +from ctms.cli.permissions import permissions_cli +from ctms.cli.roles import roles_cli +from ctms.database import SessionLocal + + +@click.group() +@click.pass_context +def cli(ctx: click.Context) -> None: + """CTMS Command Line Interface.""" + with SessionLocal() as session: + ctx.obj = {"db": session} + + +cli.add_command(clients_cli, name="clients") +cli.add_command(permissions_cli, name="permissions") +cli.add_command(roles_cli, name="roles") + +if __name__ == "__main__": + cli() diff --git a/ctms/cli/permissions.py b/ctms/cli/permissions.py new file mode 100644 index 00000000..a3be60ab --- /dev/null +++ b/ctms/cli/permissions.py @@ -0,0 +1,82 @@ +import sys + +import click +from sqlalchemy.orm import Session + +from ctms.models import Permissions + + +@click.group() +@click.pass_context +def permissions_cli(ctx: click.Context) -> None: + """Manage permissions.""" + ctx.ensure_object(dict) + + +@permissions_cli.command("list") +@click.pass_context +def list_permissions(ctx: click.Context) -> None: + """List all available permissions.""" + db: Session = ctx.obj["db"] + + permissions: list[Permissions] = db.query(Permissions).all() + + if not permissions: + click.echo("No permissions found.") + return + + click.echo("Available Permissions:") + for perm in permissions: + click.echo(f"- {perm.name}: {perm.description or 'No description'}") + + +@permissions_cli.command("create") +@click.argument("permission_name", type=str) +@click.argument("description", required=False, default="", type=str) +@click.pass_context +def create_permission(ctx: click.Context, permission_name: str, description: str) -> None: + """Create a new permission.""" + db: Session = ctx.obj["db"] + + # Check if the permission already exists. + existing_permission: Permissions | None = db.query(Permissions).filter(Permissions.name == permission_name).first() + if existing_permission: + click.echo(f"Permission '{permission_name}' already exists.") + sys.exit(10) + + permission: Permissions = Permissions(name=permission_name, description=description) + db.add(permission) + + db.commit() + click.echo(f"✅ Created permission '{permission_name}' with description: '{description}'.") + + +@permissions_cli.command("delete") +@click.argument("permission_name", type=str) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.") +@click.pass_context +def delete_permission(ctx: click.Context, permission_name: str, yes: bool) -> None: + """Delete a permission, but only if no roles are using it.""" + db: Session = ctx.obj["db"] + + permission: Permissions | None = db.query(Permissions).filter(Permissions.name == permission_name).first() + if not permission: + click.echo(f"Permission '{permission_name}' not found.") + sys.exit(4) + + # Check if any roles have this permission. + if permission.roles: + click.echo(f"Cannot delete permission '{permission_name}' because it is assigned to roles.") + click.echo("To proceed, revoke the permission from roles first:") + for role_perm in permission.roles: + click.echo(f" ctms-cli roles revoke {role_perm.role.name} {permission_name}") + sys.exit(10) + + if not yes and not click.confirm(f"Are you sure you want to delete permission '{permission_name}'?"): + click.echo("Operation cancelled.") + return + + # Safe to delete the permission. + db.delete(permission) + db.commit() + click.echo(f"✅ Successfully deleted permission '{permission_name}'.") diff --git a/ctms/cli/roles.py b/ctms/cli/roles.py new file mode 100644 index 00000000..061ceb00 --- /dev/null +++ b/ctms/cli/roles.py @@ -0,0 +1,183 @@ +import sys + +import click +from sqlalchemy.orm import Session + +from ctms.models import Permissions, RolePermissions, Roles + + +@click.group() +@click.pass_context +def roles_cli(ctx: click.Context) -> None: + """Manage roles.""" + ctx.ensure_object(dict) + + +@roles_cli.command("list") +@click.pass_context +def list_roles(ctx: click.Context) -> None: + """List all available roles.""" + db: Session = ctx.obj["db"] + roles: list[Roles] = db.query(Roles).all() + + if not roles: + click.echo("No roles found.") + return + + for role in roles: + click.echo(f"- {role.name}: {role.description or 'No description'}") + + +@roles_cli.command("show") +@click.argument("role_name", type=str) +@click.pass_context +def show_role(ctx: click.Context, role_name: str) -> None: + """Show details of a specific role, including assigned permissions and API clients.""" + db: Session = ctx.obj["db"] + + role: Roles | None = db.query(Roles).filter(Roles.name == role_name).first() + if not role: + click.echo(f"Role '{role_name}' not found.") + sys.exit(4) + + click.echo(f"Role: {role.name}") + click.echo(f"Description: {role.description or 'No description'}") + + if role.permissions: + click.echo("Permissions:") + for rp in role.permissions: + click.echo(f" - {rp.permission.name}: {rp.permission.description or 'No description'}") + else: + click.echo("No permissions assigned.") + + if role.api_clients: + click.echo("Assigned API Clients:") + for api_client_role in role.api_clients: + click.echo(f" - {api_client_role.api_client_id}") + else: + click.echo("No API clients assigned to this role.") + + +@roles_cli.command("create") +@click.argument("role_name", type=str) +@click.argument("description", required=False, default="", type=str) +@click.pass_context +def create_role(ctx: click.Context, role_name: str, description: str) -> None: + """Create a new role.""" + db: Session = ctx.obj["db"] + + # Check if role already exists. + existing_role: Roles | None = db.query(Roles).filter(Roles.name == role_name).first() + if existing_role: + click.echo(f"Role '{role_name}' already exists.") + sys.exit(10) + + role: Roles = Roles(name=role_name, description=description) + db.add(role) + + db.commit() + click.echo(f"✅ Created role '{role_name}' with description: '{description}'.") + + +@roles_cli.command("grant") +@click.argument("role_name", type=str) +@click.argument("permission_name", type=str) +@click.pass_context +def grant_permission(ctx: click.Context, role_name: str, permission_name: str) -> None: + """Grant a permission to a role.""" + db: Session = ctx.obj["db"] + + role: Roles | None = db.query(Roles).filter(Roles.name == role_name).first() + if not role: + click.echo(f"Role '{role_name}' not found.") + sys.exit(4) + + permission: Permissions | None = db.query(Permissions).filter(Permissions.name == permission_name).first() + if not permission: + click.echo(f"Permission '{permission_name}' not found.") + sys.exit(4) + + existing: RolePermissions | None = db.query(RolePermissions).filter_by(role_id=role.id, permission_id=permission.id).first() + if existing: + click.echo(f"Role '{role_name}' already has permission '{permission_name}'.") + sys.exit(10) + + new_role_permission: RolePermissions = RolePermissions(role_id=role.id, permission_id=permission.id) + db.add(new_role_permission) + + db.commit() + click.echo(f"✅ Granted permission '{permission_name}' to role '{role_name}'.") + + +@roles_cli.command("revoke") +@click.argument("role_name", type=str) +@click.argument("permission_name", type=str) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.") +@click.pass_context +def revoke_permission(ctx: click.Context, role_name: str, permission_name: str, yes: bool) -> None: + """Revoke a permission from a role.""" + db: Session = ctx.obj["db"] + + role: Roles | None = db.query(Roles).filter(Roles.name == role_name).first() + if not role: + click.echo(f"Role '{role_name}' not found.") + sys.exit(4) + + permission: Permissions | None = db.query(Permissions).filter(Permissions.name == permission_name).first() + if not permission: + click.echo(f"Permission '{permission_name}' not found.") + sys.exit(4) + + role_permission = next((rp for rp in role.permissions if rp.permission_id == permission.id), None) + + if not role_permission: + click.echo(f"Role '{role_name}' does not have permission '{permission_name}'.") + sys.exit(10) + + if not yes and not click.confirm(f"Are you sure you want to revoke permission '{permission_name}' from role '{role_name}'?"): + click.echo("Operation cancelled.") + return + + db.delete(role_permission) + db.commit() + + click.echo(f"✅ Revoked permission '{permission_name}' from role '{role_name}'.") + + +@roles_cli.command("delete") +@click.argument("role_name", type=str) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.") +@click.pass_context +def delete_role(ctx: click.Context, role_name: str, yes: bool) -> None: + """Delete a role, but only if no API clients or permissions are using it.""" + db: Session = ctx.obj["db"] + + role: Roles | None = db.query(Roles).filter(Roles.name == role_name).first() + if not role: + click.echo(f"Role '{role_name}' not found.") + sys.exit(4) + + # Check if any API clients are assigned this role. + if role.api_clients: + click.echo(f"Cannot delete role '{role_name}' because it is assigned to API clients.") + click.echo("To proceed, remove the role from clients first:") + for client_role in role.api_clients: + click.echo(f" ctms-cli clients revoke {client_role.api_client_id} {role_name}") + sys.exit(10) + + # Check if any permissions are assigned to this role. + if role.permissions: + click.echo(f"Cannot delete role '{role_name}' because it has permissions assigned.") + click.echo("To proceed, revoke these permissions first:") + for role_perm in role.permissions: + click.echo(f" ctms-cli roles revoke {role_name} {role_perm.permission.name}") + sys.exit(10) + + if not yes and not click.confirm(f"Are you sure you want to delete role '{role_name}'?"): + click.echo("Operation cancelled.") + return + + # Safe to delete the role. + db.delete(role) + db.commit() + click.echo(f"✅ Successfully deleted role '{role_name}'.") diff --git a/ctms/crud.py b/ctms/crud.py index 3281be74..d2782df0 100644 --- a/ctms/crud.py +++ b/ctms/crud.py @@ -498,6 +498,11 @@ def update_api_client_last_access(db: Session, api_client: ApiClient): db.add(api_client) +def update_api_client_secret(db: Session, api_client: ApiClient, secret): + api_client.hashed_secret = hash_password(secret) + db.add(api_client) + + def get_contacts_from_newsletter(dbsession, newsletter_name): entries = ( dbsession.query(Newsletter) diff --git a/ctms/models.py b/ctms/models.py index e3d7ce81..315a40e9 100644 --- a/ctms/models.py +++ b/ctms/models.py @@ -186,7 +186,7 @@ class ApiClient(Base, TimestampMixin): last_access = mapped_column(DateTime(timezone=True)) # Relationships - roles = relationship("ApiClientRoles", back_populates="api_client") + roles = relationship("ApiClientRoles", back_populates="api_client", lazy="joined") class MozillaFoundationContact(Base, TimestampMixin): @@ -212,8 +212,8 @@ class Roles(Base): description = mapped_column(Text, nullable=True) # Relationships - permissions = relationship("RolePermissions", back_populates="role") - api_clients = relationship("ApiClientRoles", back_populates="role") + permissions = relationship("RolePermissions", back_populates="role", lazy="joined") + api_clients = relationship("ApiClientRoles", back_populates="role", lazy="joined") class Permissions(Base): @@ -224,7 +224,7 @@ class Permissions(Base): description = mapped_column(Text, nullable=True) # Relationships - roles = relationship("RolePermissions", back_populates="permission") + roles = relationship("RolePermissions", back_populates="permission", lazy="joined") class RolePermissions(Base): @@ -236,7 +236,7 @@ class RolePermissions(Base): # Relationships role = relationship("Roles", back_populates="permissions") - permission = relationship("Permissions", back_populates="roles") + permission = relationship("Permissions", back_populates="roles", lazy="joined") class ApiClientRoles(Base): diff --git a/docs/README.md b/docs/README.md index e72981a1..3f025c3f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,4 +3,5 @@ - [Configuration](configuration.md) - [Deployment Guide](deployment_guide.md) - [Developer Setup](developer_setup.md) +- [CTMS CLI](cli.md) - [Testing Strategy](testing_strategy.md) diff --git a/docs/access_controls.md b/docs/access_controls.md index 48daef69..38fac70d 100644 --- a/docs/access_controls.md +++ b/docs/access_controls.md @@ -5,11 +5,13 @@ CTMS uses **Role-Based Access Control (RBAC)** to manage access to resources. Th - **Roles**: Group permissions together for easier management. - **API Clients**: The oAuth clients that authenticate and receive roles. +See the [CTMS CLI](./cli.md) documentation on how to manage API clients, roles, and permissions. + --- ## Permissions -**Permissions** define **what actions can be performed** within the system. They are the +**Permissions** define **what actions can be performed** within the system. They are the **lowest level of access control** and must be assigned to **roles**. ### Key Characteristics diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..3f329aee --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,162 @@ +# **📖 CTMS CLI: Managing API Clients, Roles, and Permissions** + +The **CTMS CLI** provides a command-line interface for managing API clients, roles, and permissions +within the system. It allows administrators to control access by defining **permissions**, assigning +**permissions** to **roles**, and granting **roles to API clients**. + +This document outlines how to use the CTMS CLI to manage these entities. + +## Confirmation Prompts for Destructive Actions + +To prevent accidental data loss, all destructive operations (delete, revoke, disable, rotate-secret) +will prompt for confirmation before proceeding. You can bypass these prompts by using the `--yes` or `-y` +flag when running these commands, which is especially useful for scripting or automation. + +Example: +```sh +# With confirmation prompt: +ctms-cli clients delete id_client123 + +# Bypass confirmation prompt: +ctms-cli clients delete --yes id_client123 +``` + +## Permissions + +### Listing All Permissions +To see all available permissions: +```sh +ctms-cli permissions list +``` + +### Adding a New Permission +Permissions control what actions a **role** is allowed to perform. To create a new permission, use: + +```sh +ctms-cli permissions create "" +``` + +### Deleting a Permission +Delete the permission from the database. If the permission is assigned to a **role**, the permission +will need to be revoked from the **role** first. The CLI will output the commands needed to be +performed. +```sh +ctms-cli permissions delete +``` + +A confirmation prompt will appear before deletion. + +## Roles + +### Listing All Permissions +To see all available roles: +```sh +ctms-cli roles list +``` + +### Showing Role Details +To view a role’s permissions and assigned API clients: +```sh +ctms-cli roles show +``` + +### Adding a New Role +Roles define **groups of permissions** that can be assigned to **API clients**. + +To create a new role, use: +```sh +ctms-cli roles create "" +``` + +### Granting Permissions to a Role +Assign a permission to a role: +```sh +ctms-cli roles grant +``` + +### Revoking a Permission from a Role +If you need to remove a permission from a role: +```sh +ctms-cli roles revoke +``` + +A confirmation prompt will appear before the permission is revoked. + +### Deleting a Role +Delete the role from the database. If the role is assigned to an **API client**, the role will need +to be revoked from the **API client** first. The CLI will output the commands needed to be performed. +```sh +ctms-cli roles delete +``` + +A confirmation prompt will appear before deletion. + +## API Clients + +### Listing All API Clients +To see all available API clients: +```sh +ctms-cli clients list +``` + +### Showing API Client Details +To check an API client’s assigned roles and status: +```sh +ctms-cli clients show +``` + +### Adding a New API Client +API clients are entities that authenticate and are **assigned roles**. + +To create a new API client: +```sh +ctms-cli clients create +``` + +The `client_id` must start with `"id_"` and only use alphanumeric characters plus `"-"`, `"_"`, or +`"."`. + +This command will output the generated **client ID** and **client secret** that is used to get an +oAuth token needed to access the CTMS API. The credentials and instructions will be printed out to +the console. Be sure to save the credentials in a secure location. + +### Rotate the API Client Secret +When needed, the API client secret can be rotated: +```sh +ctms-cli clients rotate-secret +``` + +A confirmation prompt will appear before rotating the secret. + +The command will print out the new API client credentials to the console. + +### Granting a Role to an API Client +To assign a role to an API client: +```sh +ctms-cli clients grant +``` + +### Revoking a Role from an API Client +To remove a role from an API client: +```sh +ctms-cli clients revoke +``` + +A confirmation prompt will appear before the role is revoked. + +### Enabling / Disabling an API Client +Enable or disable an API client: +```sh +ctms-cli clients enable +ctms-cli clients disable +``` + +When disabling a client, a confirmation prompt will appear. + +### Deleting an API Client +To delete an API client from the database: +```sh +ctms-cli clients delete +``` + +A confirmation prompt will appear before deletion. diff --git a/docs/developer_setup.md b/docs/developer_setup.md index 74f236a2..122b2247 100644 --- a/docs/developer_setup.md +++ b/docs/developer_setup.md @@ -124,22 +124,19 @@ The adminer website runs at http://localhost:8080. Log in with these credentials ## OAuth2 Client Credentials The API uses [OAuth 2 Client Credentials](https://oauth.net/2/grant-types/client-credentials/) -for authentication. To generate credentials for your development environment: +for authentication. To generate credentials for your development environment use the [CTMS CLI](./cli.md): ```sh make shell # Enter the web container -ctms/bin/client_credentials.py test --email test@example.com +ctms-cli clients create ``` This will print out new client credentials, such as: ``` -Your OAuth2 client credentials are: - - client_id: id_test - client_secret: secret_dGhpcyBpcyBubyBzZWNyZXQu - -... +** 🔑 Client Credentials -- Store Securely! 🔑 ** + - Client ID: id_test + - Client Secret: secret_JKaKrCCeIucEANisI-z-m4TTTO27ML-TELe2gezWDTI ``` You can use these on the interactive Swagger docs by clicking the "**Authorize**" button. diff --git a/poetry.lock b/poetry.lock index 01b88961..37b427c1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2472,4 +2472,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.12,<4" -content-hash = "8172b5552caa1169afa28efe272082a8932faf3b78b5454ee0ea8b51305e8bf7" +content-hash = "1ebe30429ca70ee8e468dd0681f616462e2c7beb06799539980158d7a052843b" diff --git a/pyproject.toml b/pyproject.toml index d4218066..354ec7b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,8 @@ authors = [ { name = "CTMS Reviewers" }, ] requires-python = ">=3.12,<4" -package-mode = false +package-mode = true +packages = [{ include = "ctms" }] classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", @@ -20,6 +21,7 @@ classifiers = [ dependencies = [ "alembic>=1.14", "argon2-cffi>=23.1", + "click (>=8.1.8,<9.0.0)", "colorama>=0.4.6", "dockerflow[fastapi]>=2024.4.2", "fastapi>=0.115.6", @@ -40,6 +42,9 @@ dependencies = [ "uvicorn[standard]>=0.34", ] +[project.scripts] +ctms-cli = "ctms.cli.main:cli" + # TODO: Move to PEP 735 dependency-groups when supported: # https://github.com/python-poetry/poetry/issues/9751 [tool.poetry.group.dev.dependencies] @@ -97,6 +102,8 @@ omit = [ '*/.venv/*', '*/.tox/*', '*/virtualenvs/*', + 'migrations/*', + 'tests/*', ] [tool.coverage.report] diff --git a/tests/unit/cli/__init__.py b/tests/unit/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/cli/conftest.py b/tests/unit/cli/conftest.py new file mode 100644 index 00000000..a012ae86 --- /dev/null +++ b/tests/unit/cli/conftest.py @@ -0,0 +1,8 @@ +import pytest +from click.testing import CliRunner + + +@pytest.fixture +def clirunner() -> CliRunner: + """Provides a CLI test runner.""" + return CliRunner() diff --git a/tests/unit/cli/test_clients.py b/tests/unit/cli/test_clients.py new file mode 100644 index 00000000..06be86f4 --- /dev/null +++ b/tests/unit/cli/test_clients.py @@ -0,0 +1,461 @@ +from ctms import models +from ctms.cli.main import cli +from ctms.permissions import ADMIN_ROLE_NAME + +# Tests for `ctms-cli clients list` + + +def test_list_clients(clirunner, api_client_factory): + """Test `ctms-cli clients list` command.""" + # Create some clients + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + c2 = api_client_factory(client_id="id_client2", email="client2@example.com", enabled=False) + + result = clirunner.invoke(cli, ["clients", "list"]) + + assert result.exit_code == 0 + assert f"{c1.client_id} ({c1.email}) - Enabled | Last Access: Never" in result.output + assert f"{c2.client_id} ({c2.email}) - Disabled" in result.output + + +def test_list_clients_no_clients(clirunner): + """Test `ctms-cli clients list` command when no clients exist.""" + + result = clirunner.invoke(cli, ["clients", "list"]) + + assert result.exit_code == 0 + assert "No API clients found." in result.output + + +# Tests for `ctms-cli clients show` + + +def test_show_client(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients show` command.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + admin_role = dbsession.query(models.Roles).filter_by(name=ADMIN_ROLE_NAME).one() + c1.roles.append(models.ApiClientRoles(role=admin_role)) + + dbsession.commit() + + result = clirunner.invoke(cli, ["clients", "show", c1.client_id]) + + assert result.exit_code == 0 + assert f"API Client: {c1.client_id}" in result.output + assert f"Email: {c1.email}" in result.output + assert f"Enabled: {'Yes' if c1.enabled else 'No'}" in result.output + assert f"Last Access: {c1.last_access or 'Never'}" in result.output + assert "Assigned Roles:" in result.output + assert f" - {c1.roles[0].role.name}" in result.output + + +def test_show_client_not_found(clirunner): + """Test `ctms-cli clients show` command when client not found.""" + result = clirunner.invoke(cli, ["clients", "show", "id_non_existent_client"]) + + assert result.exit_code == 4 + assert "API client 'id_non_existent_client' not found." in result.output + + +def test_show_client_no_roles(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients show` command when client has no roles.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + + result = clirunner.invoke(cli, ["clients", "show", c1.client_id]) + + assert result.exit_code == 0 + assert f"API Client: {c1.client_id}" in result.output + assert f"Email: {c1.email}" in result.output + assert f"Enabled: {'Yes' if c1.enabled else 'No'}" in result.output + assert f"Last Access: {c1.last_access or 'Never'}" in result.output + assert "No roles assigned." in result.output + + +# Tests for `ctms-cli clients grant` + + +def test_grant_role(dbsession, clirunner, api_client_factory, role_factory): + """Test `ctms-cli clients grant` command.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + r1 = role_factory(name="role1") + + result = clirunner.invoke(cli, ["clients", "grant", c1.client_id, r1.name]) + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert f"✅ Granted role '{r1.name}' to API client '{c1.client_id}'." in result.output + assert len(c1.roles) == 1 + assert c1.roles[0].role.name == r1.name + + +def test_grant_role_already_exists(dbsession, clirunner, api_client_factory, role_factory): + """Test `ctms-cli clients grant` command when role already exists.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + r1 = role_factory(name="role1") + c1.roles.append(models.ApiClientRoles(role=r1)) + dbsession.commit() + + result = clirunner.invoke(cli, ["clients", "grant", c1.client_id, r1.name]) + + assert result.exit_code == 0 + assert f"API client '{c1.client_id}' already has role '{r1.name}'." in result.output + + +def test_grant_role_client_not_found(clirunner): + """Test `ctms-cli clients grant` command when client not found.""" + result = clirunner.invoke(cli, ["clients", "grant", "id_non_existent_client", "role1"]) + + assert result.exit_code == 4 + assert "API client 'id_non_existent_client' not found." in result.output + + +def test_grant_role_role_not_found(clirunner, api_client_factory): + """Test `ctms-cli clients grant` command when role not found.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + + result = clirunner.invoke(cli, ["clients", "grant", c1.client_id, "non_existent_role"]) + + assert result.exit_code == 4 + assert "Role 'non_existent_role' not found." in result.output + + +# Tests for `ctms-cli clients revoke` + + +def test_revoke_role(dbsession, clirunner, api_client_factory, role_factory): + """Test `ctms-cli clients revoke` command.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + r1 = role_factory(name="role1") + c1.roles.append(models.ApiClientRoles(role=r1)) + dbsession.commit() + + result = clirunner.invoke(cli, ["clients", "revoke", "--yes", c1.client_id, r1.name]) + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert f"✅ Revoked role '{r1.name}' from API client '{c1.client_id}'." in result.output + assert len(c1.roles) == 0 + + +def test_revoke_role_with_confirmation_declined(dbsession, clirunner, api_client_factory, role_factory): + """Test `ctms-cli clients revoke` command when confirmation is declined.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + r1 = role_factory(name="role1") + c1.roles.append(models.ApiClientRoles(role=r1)) + dbsession.commit() + + # Simulate user entering 'n' at the prompt + result = clirunner.invoke(cli, ["clients", "revoke", c1.client_id, r1.name], input="n\n") + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert "Operation cancelled." in result.output + # Role should still be assigned + assert len(c1.roles) == 1 + + +def test_revoke_role_with_confirmation_accepted(dbsession, clirunner, api_client_factory, role_factory): + """Test `ctms-cli clients revoke` command when confirmation is accepted.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + r1 = role_factory(name="role1") + c1.roles.append(models.ApiClientRoles(role=r1)) + dbsession.commit() + + # Simulate user entering 'y' at the prompt + result = clirunner.invoke(cli, ["clients", "revoke", c1.client_id, r1.name], input="y\n") + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert f"✅ Revoked role '{r1.name}' from API client '{c1.client_id}'." in result.output + # Role should be revoked + assert len(c1.roles) == 0 + + +def test_revoke_role_not_assigned(dbsession, clirunner, api_client_factory, role_factory): + """Test `ctms-cli clients revoke` command when role not assigned.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + r1 = role_factory(name="role1") + + result = clirunner.invoke(cli, ["clients", "revoke", c1.client_id, r1.name]) + assert result.exit_code == 10 + assert f"API client '{c1.client_id}' does not have role '{r1.name}'." in result.output + + +def test_revoke_client_not_found(clirunner): + """Test `ctms-cli clients revoke` command when client not found.""" + result = clirunner.invoke(cli, ["clients", "revoke", "id_non_existent_client", "role1"]) + + assert result.exit_code == 4 + assert "API client 'id_non_existent_client' not found." in result.output + + +def test_revoke_role_not_found(clirunner, api_client_factory): + """Test `ctms-cli clients revoke` command when role not found.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + + result = clirunner.invoke(cli, ["clients", "revoke", c1.client_id, "non_existent_role"]) + + assert result.exit_code == 4 + assert "Role 'non_existent_role' not found." in result.output + + +# Tests for `ctms-cli clients create` + + +def test_create_client(clirunner): + """Test `ctms-cli clients create` command.""" + result = clirunner.invoke(cli, ["clients", "create", "id_client1", "client1@example.com"]) + + assert result.exit_code == 0 + assert "✅ Created API client 'id_client1' with email: 'client1@example.com'." in result.output + assert "** 🔑 Client Credentials -- Store Securely! 🔑 **" in result.output + assert "- Client ID: id_client1" in result.output + assert "- Client Secret:" in result.output + + +def test_create_client_invalid_email(clirunner): + """Test `ctms-cli clients create` command when email is invalid.""" + result = clirunner.invoke(cli, ["clients", "create", "id_client1", "invalid_email"]) + + assert result.exit_code == 2 + assert "Error: Invalid value for 'EMAIL': value is not a valid email address: An email address must have an @-sign." in result.output + + +def test_create_client_invalid_id(clirunner): + """Test `ctms-cli clients create` command when client ID is invalid.""" + result = clirunner.invoke(cli, ["clients", "create", "invalid_id", "client1@example.com"]) + assert result.exit_code == 2 + assert ( + "Client ID 'invalid_id' is invalid. It must start with 'id_' and should contain only " + "alphanumeric characters, hyphens, underscores, or periods." in result.output + ) + + +def test_create_client_already_exists(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients create` command when client already exists.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + + result = clirunner.invoke(cli, ["clients", "create", c1.client_id, c1.email]) + + assert result.exit_code == 10 + assert "API client 'id_client1' already exists." in result.output + + +# Tests for `ctms-cli clients delete` + + +def test_delete_client(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients delete` command.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + client_id = c1.client_id + + result = clirunner.invoke(cli, ["clients", "delete", "--yes", client_id]) + + assert result.exit_code == 0 + assert f"✅ Deleted API client '{client_id}'." in result.output + assert dbsession.query(models.ApiClient).filter(models.ApiClient.client_id == client_id).first() is None + + +def test_delete_client_with_confirmation_declined(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients delete` command when confirmation is declined.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + client_id = c1.client_id + + # Simulate user entering 'n' at the prompt + result = clirunner.invoke(cli, ["clients", "delete", client_id], input="n\n") + + assert result.exit_code == 0 + assert "Operation cancelled." in result.output + # Verify client was not deleted + assert dbsession.query(models.ApiClient).filter(models.ApiClient.client_id == client_id).first() is not None + + +def test_delete_client_with_confirmation_accepted(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients delete` command when confirmation is accepted.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + client_id = c1.client_id + + # Simulate user entering 'y' at the prompt + result = clirunner.invoke(cli, ["clients", "delete", client_id], input="y\n") + + assert result.exit_code == 0 + assert f"✅ Deleted API client '{client_id}'." in result.output + # Verify client was deleted + assert dbsession.query(models.ApiClient).filter(models.ApiClient.client_id == client_id).first() is None + + +def test_delete_client_not_found(clirunner): + """Test `ctms-cli clients delete` command when client not found.""" + result = clirunner.invoke(cli, ["clients", "delete", "id_non_existent"]) + + assert result.exit_code == 4 + assert "API client 'id_non_existent' not found." in result.output + + +# Tests for `ctms-cli clients enable` + + +def test_enable_client(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients enable` command.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com", enabled=False) + client_id = c1.client_id + + result = clirunner.invoke(cli, ["clients", "enable", client_id]) + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert f"✅ Enabled API client '{client_id}'." in result.output + assert c1.enabled is True + + +def test_enable_client_already_enabled(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients enable` command when client is already enabled.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com", enabled=True) + client_id = c1.client_id + + result = clirunner.invoke(cli, ["clients", "enable", client_id]) + + assert result.exit_code == 0 + assert f"API client '{client_id}' is already enabled." in result.output + + +def test_enable_client_not_found(clirunner): + """Test `ctms-cli clients enable` command when client not found.""" + result = clirunner.invoke(cli, ["clients", "enable", "id_non_existent"]) + + assert result.exit_code == 4 + assert "API client 'id_non_existent' not found." in result.output + + +# Tests for `ctms-cli clients disable` + + +def test_disable_client(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients disable` command.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com", enabled=True) + client_id = c1.client_id + + result = clirunner.invoke(cli, ["clients", "disable", "--yes", client_id]) + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert f"✅ Disabled API client '{client_id}'." in result.output + assert c1.enabled is False + + +def test_disable_client_with_confirmation_declined(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients disable` command when confirmation is declined.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com", enabled=True) + client_id = c1.client_id + + # Simulate user entering 'n' at the prompt + result = clirunner.invoke(cli, ["clients", "disable", client_id], input="n\n") + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert "Operation cancelled." in result.output + # Client should still be enabled + assert c1.enabled is True + + +def test_disable_client_with_confirmation_accepted(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients disable` command when confirmation is accepted.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com", enabled=True) + client_id = c1.client_id + + # Simulate user entering 'y' at the prompt + result = clirunner.invoke(cli, ["clients", "disable", client_id], input="y\n") + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert f"✅ Disabled API client '{client_id}'." in result.output + # Client should be disabled + assert c1.enabled is False + + +def test_disable_client_already_disabled(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients disable` command when client is already disabled.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com", enabled=False) + client_id = c1.client_id + + result = clirunner.invoke(cli, ["clients", "disable", client_id]) + + assert result.exit_code == 0 + assert f"API client '{client_id}' is already disabled." in result.output + + +def test_disable_client_not_found(clirunner): + """Test `ctms-cli clients disable` command when client not found.""" + result = clirunner.invoke(cli, ["clients", "disable", "id_non_existent"]) + + assert result.exit_code == 4 + assert "API client 'id_non_existent' not found." in result.output + + +# Tests for `ctms-cli clients rotate-secret` + + +def test_rotate_secret(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients rotate-secret` command.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + original_secret_hash = c1.hashed_secret + + result = clirunner.invoke(cli, ["clients", "rotate-secret", "--yes", c1.client_id]) + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert f"✅ Rotated secret for API client '{c1.client_id}'." in result.output + assert "** 🔑 Client Credentials -- Store Securely! 🔑 **" in result.output + assert f" - Client ID: {c1.client_id}" in result.output + assert " - Client Secret: " in result.output + # Verify the hash changed + assert c1.hashed_secret != original_secret_hash + + +def test_rotate_secret_with_confirmation_declined(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients rotate-secret` command when confirmation is declined.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + original_secret_hash = c1.hashed_secret + + # Simulate user entering 'n' at the prompt + result = clirunner.invoke(cli, ["clients", "rotate-secret", c1.client_id], input="n\n") + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert "Operation cancelled." in result.output + # Secret should not be rotated + assert c1.hashed_secret == original_secret_hash + + +def test_rotate_secret_with_confirmation_accepted(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients rotate-secret` command when confirmation is accepted.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + original_secret_hash = c1.hashed_secret + + # Simulate user entering 'y' at the prompt + result = clirunner.invoke(cli, ["clients", "rotate-secret", c1.client_id], input="y\n") + dbsession.refresh(c1) + + assert result.exit_code == 0 + assert f"✅ Rotated secret for API client '{c1.client_id}'." in result.output + # Secret should be rotated + assert c1.hashed_secret != original_secret_hash + + +def test_rotate_secret_for_disabled_client(dbsession, clirunner, api_client_factory): + """Test `ctms-cli clients rotate-secret` command with disabled client.""" + c1 = api_client_factory(client_id="id_client1", email="client1@example.com", enabled=False) + + # Secret can still be rotated for disabled clients - they might be re-enabled later. + result = clirunner.invoke(cli, ["clients", "rotate-secret", "--yes", c1.client_id]) + + assert result.exit_code == 0 + assert f"✅ Rotated secret for API client '{c1.client_id}'." in result.output + + +def test_rotate_secret_client_not_found(clirunner): + """Test `ctms-cli clients rotate-secret` command when client not found.""" + result = clirunner.invoke(cli, ["clients", "rotate-secret", "id_non_existent"]) + + assert result.exit_code == 4 + assert "API client 'id_non_existent' not found." in result.output diff --git a/tests/unit/cli/test_permissions.py b/tests/unit/cli/test_permissions.py new file mode 100644 index 00000000..1474123b --- /dev/null +++ b/tests/unit/cli/test_permissions.py @@ -0,0 +1,143 @@ +from ctms import models +from ctms.cli.main import cli + +# Tests for `ctms-cli permissions list` + + +def test_list_permissions(clirunner, permission_factory): + """Test `ctms-cli permissions list` command.""" + # Create some permissions + p1 = permission_factory(name="delete_object", description="Allows deletion of objects") + p2 = permission_factory(name="view_object", description="Allows viewing objects") + + result = clirunner.invoke(cli, ["permissions", "list"]) + + assert result.exit_code == 0 + assert f"{p1.name}: {p1.description}" in result.output + assert f"{p2.name}: {p2.description}" in result.output + + +def test_list_permissions_no_permissions(clirunner): + """Test `ctms-cli permissions list` command when no permissions exist.""" + result = clirunner.invoke(cli, ["permissions", "list"]) + + assert result.exit_code == 0 + assert "No permissions found." in result.output + + +# Tests for `ctms-cli permissions create` + + +def test_create_permission(dbsession, clirunner): + """Test `ctms-cli permissions create` command.""" + result = clirunner.invoke(cli, ["permissions", "create", "delete_object", "Allows deletion of objects"]) + + assert result.exit_code == 0 + assert "✅ Created permission 'delete_object' with description: 'Allows deletion of objects'." in result.output + + perm = dbsession.query(models.Permissions).filter_by(name="delete_object").first() + assert perm is not None + assert perm.description == "Allows deletion of objects" + + +def test_create_permission_no_description(clirunner): + """Test `ctms-cli permissions create` command with no description.""" + result = clirunner.invoke(cli, ["permissions", "create", "delete_object"]) + + assert result.exit_code == 0 + assert "✅ Created permission 'delete_object' with description: ''." in result.output + + +def test_create_permission_no_name(clirunner): + """Test `ctms-cli permissions create` command with no name.""" + result = clirunner.invoke(cli, ["permissions", "create"]) + + assert result.exit_code == 2 + assert "Error: Missing argument 'PERMISSION_NAME'." in result.output + + +def test_create_permission_already_exists(clirunner, permission_factory): + """Test `ctms-cli permissions create` command when permission already exists.""" + permission_factory(name="delete_object") + + result = clirunner.invoke(cli, ["permissions", "create", "delete_object", "Allows deletion of objects"]) + + assert result.exit_code == 10 + assert "Permission 'delete_object' already exists." in result.output + + +# Tests for `ctms-cli permissions delete` + + +def test_delete_permission(dbsession, clirunner, permission_factory): + """Test `ctms-cli permissions delete` command.""" + perm = permission_factory(name="delete_object") + + result = clirunner.invoke(cli, ["permissions", "delete", "--yes", "delete_object"]) + + assert result.exit_code == 0 + assert "✅ Successfully deleted permission 'delete_object'." in result.output + + perm = dbsession.query(models.Permissions).filter_by(name="delete_object").first() + assert perm is None + + +def test_delete_permission_with_confirmation_declined(dbsession, clirunner, permission_factory): + """Test `ctms-cli permissions delete` command when confirmation is declined.""" + perm = permission_factory(name="delete_object") + + # Simulate user entering 'n' at the prompt + result = clirunner.invoke(cli, ["permissions", "delete", "delete_object"], input="n\n") + + assert result.exit_code == 0 + assert "Operation cancelled." in result.output + # Permission should still exist + perm = dbsession.query(models.Permissions).filter_by(name="delete_object").first() + assert perm is not None + + +def test_delete_permission_with_confirmation_accepted(dbsession, clirunner, permission_factory): + """Test `ctms-cli permissions delete` command when confirmation is accepted.""" + perm = permission_factory(name="delete_object") + + # Simulate user entering 'y' at the prompt + result = clirunner.invoke(cli, ["permissions", "delete", "delete_object"], input="y\n") + + assert result.exit_code == 0 + assert "✅ Successfully deleted permission 'delete_object'." in result.output + # Permission should be deleted + perm = dbsession.query(models.Permissions).filter_by(name="delete_object").first() + assert perm is None + + +def test_delete_permission_not_found(clirunner): + """Test `ctms-cli permissions delete` command when permission does not exist.""" + result = clirunner.invoke(cli, ["permissions", "delete", "delete_object"]) + + assert result.exit_code == 4 + assert "Permission 'delete_object' not found." in result.output + + +def test_delete_permission_no_name(clirunner): + """Test `ctms-cli permissions delete` command with no name.""" + result = clirunner.invoke(cli, ["permissions", "delete"]) + + assert result.exit_code == 2 + assert "Error: Missing argument 'PERMISSION_NAME'." in result.output + + +def test_delete_permission_with_role(dbsession, clirunner, permission_factory, role_factory, role_permissions_factory): + """Test `ctms-cli permissions delete` command when permission is assigned to a role.""" + perm = permission_factory(name="delete_object") + role = role_factory(name="tester") + role_permissions_factory(role=role, permission=perm) + + result = clirunner.invoke(cli, ["permissions", "delete", "delete_object"]) + + assert result.exit_code == 10 + assert "Cannot delete permission 'delete_object' because it is assigned to roles." in result.output + assert f"ctms-cli roles revoke {role.name} {perm.name}" in result.output + + perm = dbsession.query(models.Permissions).filter_by(name="delete_object").first() + # The permission should not be deleted. + assert perm is not None diff --git a/tests/unit/cli/test_roles.py b/tests/unit/cli/test_roles.py new file mode 100644 index 00000000..e011ceb9 --- /dev/null +++ b/tests/unit/cli/test_roles.py @@ -0,0 +1,416 @@ +from ctms import models +from ctms.cli.main import cli +from ctms.permissions import ADMIN_ROLE_NAME + +# Tests for `ctms-cli roles list` + + +def test_list_roles(clirunner, role_factory): + """Test `ctms-cli roles list` command.""" + r1 = role_factory(name="manager", description="Manager role") + r2 = role_factory(name="viewer", description="Viewer role") + + result = clirunner.invoke(cli, ["roles", "list"]) + + assert result.exit_code == 0 + assert f"{r1.name}: {r1.description}" in result.output + assert f"{r2.name}: {r2.description}" in result.output + assert "admin: No description" in result.output + + +def test_list_roles_no_roles(dbsession, clirunner): + """Test `ctms-cli roles list` command when no roles exist.""" + # Delete the admin role. + dbsession.query(models.Roles).filter_by(name=ADMIN_ROLE_NAME).delete() + + result = clirunner.invoke(cli, ["roles", "list"]) + + assert result.exit_code == 0 + assert "No roles found." in result.output + + +# Tests for `ctms-cli roles show` + + +def test_show_role( + dbsession, + clirunner, + role_factory, + permission_factory, + role_permissions_factory, + api_client_factory, + api_client_roles_factory, +): + """Test `ctms-cli roles show` command.""" + p1 = permission_factory(name="delete_object", description="Allows deletion of objects") + p2 = permission_factory(name="view_object", description="Allows viewing objects") + + r1 = role_factory(name="manager", description="Manager role") + r2 = role_factory(name="viewer", description="Viewer role") + + role_permissions_factory(role=r1, permission=p1) + role_permissions_factory(role=r2, permission=p2) + + c1 = api_client_factory(client_id="id_client1", email="client1@example.com") + c2 = api_client_factory(client_id="id_client2", email="client2@example.com") + + api_client_roles_factory(api_client=c1, role=r1) + api_client_roles_factory(api_client=c2, role=r2) + + dbsession.commit() + + result = clirunner.invoke(cli, ["roles", "show", "manager"]) + + assert result.exit_code == 0 + assert "Role: manager" in result.output + assert "Description: Manager role" in result.output + assert "Permissions:" in result.output + assert f" - {p1.name}: {p1.description}" in result.output + assert "Assigned API Clients:" in result.output + assert f" - {c1.client_id}" in result.output + + assert f" - {p2.name}: {p2.description}" not in result.output + assert f" - {c2.client_id}" not in result.output + + +def test_show_role_no_role(clirunner): + """Test `ctms-cli roles show` command when role does not exist.""" + result = clirunner.invoke(cli, ["roles", "show", "manager"]) + + assert result.exit_code == 4 + assert "Role 'manager' not found." in result.output + + +def test_show_role_no_permissions_or_clients(clirunner, role_factory): + """Test `ctms-cli roles show` command when role has no permissions or clients.""" + role_factory(name="manager", description="Manager role") + + result = clirunner.invoke(cli, ["roles", "show", "manager"]) + + assert result.exit_code == 0 + assert "Role: manager" in result.output + assert "Description: Manager role" in result.output + assert "No permissions assigned." in result.output + assert "No API clients assigned to this role." in result.output + + +# Tests for `ctms-cli roles create` + + +def test_create_role(dbsession, clirunner): + """Test `ctms-cli roles create` command.""" + result = clirunner.invoke(cli, ["roles", "create", "manager", "Manager role"]) + + assert result.exit_code == 0 + assert "✅ Created role 'manager' with description: 'Manager role'." in result.output + + role = dbsession.query(models.Roles).filter_by(name="manager").first() + assert role is not None + assert role.description == "Manager role" + + +def test_create_role_no_description(clirunner): + """Test `ctms-cli roles create` command with no description.""" + result = clirunner.invoke(cli, ["roles", "create", "manager"]) + + assert result.exit_code == 0 + assert "✅ Created role 'manager' with description: ''." in result.output + + +def test_create_role_no_name(clirunner): + """Test `ctms-cli roles create` command with no name.""" + result = clirunner.invoke(cli, ["roles", "create"]) + + assert result.exit_code == 2 + assert "Error: Missing argument 'ROLE_NAME'." in result.output + + +def test_create_role_already_exists(clirunner, role_factory): + """Test `ctms-cli roles create` command when role already exists.""" + role_factory(name="manager") + + result = clirunner.invoke(cli, ["roles", "create", "manager", "Manager role"]) + + assert result.exit_code == 10 + assert "Role 'manager' already exists." in result.output + + +# Tests for `ctms-cli roles grant` + + +def test_grant_role(dbsession, clirunner, role_factory, permission_factory): + """Test `ctms-cli roles grant` command.""" + role_factory(name="manager", description="Manager role") + p1 = permission_factory(name="delete_object", description="Allows deletion of objects") + + dbsession.commit() + + result = clirunner.invoke(cli, ["roles", "grant", "manager", "delete_object"]) + + assert result.exit_code == 0 + assert "✅ Granted permission 'delete_object' to role 'manager'." in result.output + + role = dbsession.query(models.Roles).filter_by(name="manager").first() + assert role is not None + assert len(role.permissions) == 1 + assert role.permissions[0].permission == p1 + + +def test_grant_role_no_role(clirunner): + """Test `ctms-cli roles grant` command when role does not exist.""" + result = clirunner.invoke(cli, ["roles", "grant", "manager", "delete_object"]) + + assert result.exit_code == 4 + assert "Role 'manager' not found." in result.output + + +def test_grant_role_no_permission(clirunner, role_factory): + """Test `ctms-cli roles grant` command when permission does not exist.""" + role_factory(name="manager") + + result = clirunner.invoke(cli, ["roles", "grant", "manager", "delete_object"]) + + assert result.exit_code == 4 + assert "Permission 'delete_object' not found." in result.output + + +def test_grant_role_already_has_permission(dbsession, clirunner, role_factory, permission_factory, role_permissions_factory): + """Test `ctms-cli roles grant` command when role already has permission.""" + role = role_factory(name="manager", description="Manager role") + p1 = permission_factory(name="delete_object", description="Allows deletion of objects") + role_permissions_factory(role=role, permission=p1) + + dbsession.commit() + + result = clirunner.invoke(cli, ["roles", "grant", "manager", "delete_object"]) + + assert result.exit_code == 10 + assert "Role 'manager' already has permission 'delete_object'." in result.output + + +def test_grant_role_no_name(clirunner): + """Test `ctms-cli roles grant` command with no name.""" + result = clirunner.invoke(cli, ["roles", "grant"]) + + assert result.exit_code == 2 + assert "Error: Missing argument 'ROLE_NAME'." in result.output + + +def test_grant_role_no_permission_name(clirunner): + """Test `ctms-cli roles grant` command with no permission name.""" + result = clirunner.invoke(cli, ["roles", "grant", "manager"]) + + assert result.exit_code == 2 + assert "Error: Missing argument 'PERMISSION_NAME'." in result.output + + +# Tests for `ctms-cli roles revoke` + + +def test_revoke_role(dbsession, clirunner, role_factory, permission_factory, role_permissions_factory): + """Test `ctms-cli roles revoke` command.""" + role = role_factory(name="manager", description="Manager role") + p1 = permission_factory(name="delete_object", description="Allows deletion of objects") + role_permissions_factory(role=role, permission=p1) + + dbsession.commit() + + result = clirunner.invoke(cli, ["roles", "revoke", "--yes", "manager", "delete_object"]) + + assert result.exit_code == 0 + assert "✅ Revoked permission 'delete_object' from role 'manager'." in result.output + + role = dbsession.query(models.Roles).filter_by(name="manager").first() + assert role is not None + assert len(role.permissions) == 0 + + +def test_revoke_role_with_confirmation_declined(dbsession, clirunner, role_factory, permission_factory, role_permissions_factory): + """Test `ctms-cli roles revoke` command when confirmation is declined.""" + role = role_factory(name="manager", description="Manager role") + p1 = permission_factory(name="delete_object", description="Allows deletion of objects") + role_permissions_factory(role=role, permission=p1) + + dbsession.commit() + + # Simulate user entering 'n' at the prompt + result = clirunner.invoke(cli, ["roles", "revoke", "manager", "delete_object"], input="n\n") + + assert result.exit_code == 0 + assert "Operation cancelled." in result.output + # Permission should still be assigned to the role + role = dbsession.query(models.Roles).filter_by(name="manager").first() + assert len(role.permissions) == 1 + + +def test_revoke_role_with_confirmation_accepted(dbsession, clirunner, role_factory, permission_factory, role_permissions_factory): + """Test `ctms-cli roles revoke` command when confirmation is accepted.""" + role = role_factory(name="manager", description="Manager role") + p1 = permission_factory(name="delete_object", description="Allows deletion of objects") + role_permissions_factory(role=role, permission=p1) + + dbsession.commit() + + # Simulate user entering 'y' at the prompt + result = clirunner.invoke(cli, ["roles", "revoke", "manager", "delete_object"], input="y\n") + + assert result.exit_code == 0 + assert "✅ Revoked permission 'delete_object' from role 'manager'." in result.output + # Permission should be revoked from the role + role = dbsession.query(models.Roles).filter_by(name="manager").first() + assert len(role.permissions) == 0 + + +def test_revoke_role_no_role(clirunner): + """Test `ctms-cli roles revoke` command when role does not exist.""" + result = clirunner.invoke(cli, ["roles", "revoke", "manager", "delete_object"]) + + assert result.exit_code == 4 + assert "Role 'manager' not found." in result.output + + +def test_revoke_role_no_permission(clirunner, role_factory): + """Test `ctms-cli roles revoke` command when permission does not exist.""" + role_factory(name="manager") + + result = clirunner.invoke(cli, ["roles", "revoke", "manager", "delete_object"]) + + assert result.exit_code == 4 + assert "Permission 'delete_object' not found." in result.output + + +def test_revoke_role_does_not_have_permission(dbsession, clirunner, role_factory, permission_factory): + """Test `ctms-cli roles revoke` command when role does not have permission.""" + role_factory(name="manager", description="Manager role") + permission_factory(name="delete_object", description="Allows deletion of objects") + + dbsession.commit() + + result = clirunner.invoke(cli, ["roles", "revoke", "manager", "delete_object"]) + + assert result.exit_code == 10 + assert "Role 'manager' does not have permission 'delete_object'." in result.output + + +def test_revoke_role_no_name(clirunner): + """Test `ctms-cli roles revoke` command with no name.""" + result = clirunner.invoke(cli, ["roles", "revoke"]) + + assert result.exit_code == 2 + assert "Error: Missing argument 'ROLE_NAME'." in result.output + + +def test_revoke_role_no_permission_name(clirunner): + """Test `ctms-cli roles revoke` command with no permission name.""" + result = clirunner.invoke(cli, ["roles", "revoke", "manager"]) + + assert result.exit_code == 2 + assert "Error: Missing argument 'PERMISSION_NAME'." in result.output + + +# Tests for `ctms-cli roles delete` + + +def test_delete_role(dbsession, clirunner, role_factory): + """Test `ctms-cli roles delete` command.""" + role = role_factory(name="manager", description="Manager role") + + dbsession.commit() + + result = clirunner.invoke(cli, ["roles", "delete", "--yes", "manager"]) + + assert result.exit_code == 0 + assert "✅ Successfully deleted role 'manager'." in result.output + + role = dbsession.query(models.Roles).filter_by(name="manager").first() + assert role is None + + +def test_delete_role_with_confirmation_declined(dbsession, clirunner, role_factory): + """Test `ctms-cli roles delete` command when confirmation is declined.""" + role = role_factory(name="manager", description="Manager role") + + dbsession.commit() + + # Simulate user entering 'n' at the prompt + result = clirunner.invoke(cli, ["roles", "delete", "manager"], input="n\n") + + assert result.exit_code == 0 + assert "Operation cancelled." in result.output + # Role should still exist + role = dbsession.query(models.Roles).filter_by(name="manager").first() + assert role is not None + + +def test_delete_role_with_confirmation_accepted(dbsession, clirunner, role_factory): + """Test `ctms-cli roles delete` command when confirmation is accepted.""" + role = role_factory(name="manager", description="Manager role") + + dbsession.commit() + + # Simulate user entering 'y' at the prompt + result = clirunner.invoke(cli, ["roles", "delete", "manager"], input="y\n") + + assert result.exit_code == 0 + assert "✅ Successfully deleted role 'manager'." in result.output + # Role should be deleted + role = dbsession.query(models.Roles).filter_by(name="manager").first() + assert role is None + + +def test_delete_role_no_role(clirunner): + """Test `ctms-cli roles delete` command when role does not exist.""" + result = clirunner.invoke(cli, ["roles", "delete", "manager"]) + + assert result.exit_code == 4 + assert "Role 'manager' not found." in result.output + + +def test_delete_role_no_name(clirunner): + """Test `ctms-cli roles delete` command with no name.""" + result = clirunner.invoke(cli, ["roles", "delete"]) + + assert result.exit_code == 2 + assert "Error: Missing argument 'ROLE_NAME'." in result.output + + +def test_delete_role_with_permissions( + dbsession, + clirunner, + role_factory, + permission_factory, + role_permissions_factory, +): + """Test `ctms-cli roles delete` command when role has permissions.""" + role = role_factory(name="manager", description="Manager role") + permission = permission_factory(name="delete_object", description="Allows deletion of objects") + role_permissions_factory(role=role, permission=permission) + + dbsession.commit() + + result = clirunner.invoke(cli, ["roles", "delete", "manager"]) + + assert result.exit_code == 10 + assert "Cannot delete role 'manager' because it has permissions assigned." in result.output + assert f"ctms-cli roles revoke {role.name} {permission.name}" in result.output + + +def test_delete_role_with_clients( + dbsession, + clirunner, + role_factory, + api_client_factory, + api_client_roles_factory, +): + """Test `ctms-cli roles delete` command when role has clients.""" + role = role_factory(name="manager", description="Manager role") + client = api_client_factory(client_id="id_client1", email="client1@example.com") + api_client_roles_factory(api_client=client, role=role) + + dbsession.commit() + + result = clirunner.invoke(cli, ["roles", "delete", "manager"]) + + assert result.exit_code == 10 + assert "Cannot delete role 'manager' because it is assigned to API clients." in result.output + assert f"ctms-cli clients revoke {client.client_id} {role.name}" in result.output From dd15299e125af90f601ea359f30f4566e3842c12 Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Mon, 10 Mar 2025 15:40:29 -0700 Subject: [PATCH 4/5] PR review updates --- ctms/permissions.py | 5 +---- docs/access_controls.md | 11 +++++------ docs/cli.md | 4 ++-- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/ctms/permissions.py b/ctms/permissions.py index 07a492c0..58978102 100644 --- a/ctms/permissions.py +++ b/ctms/permissions.py @@ -66,10 +66,7 @@ def has_any_permission(db: Session, api_client_id: str, permission_names: list[s .where(ApiClientRoles.api_client_id == api_client_id) ).scalar() - if has_perm: - return True - - return False + return bool(has_perm) def with_permission(*permission_names: str): diff --git a/docs/access_controls.md b/docs/access_controls.md index 38fac70d..fdd444e4 100644 --- a/docs/access_controls.md +++ b/docs/access_controls.md @@ -21,9 +21,9 @@ See the [CTMS CLI](./cli.md) documentation on how to manage API clients, roles, ### Example Permissions | Permission Name | Description | -|----------------|-------------| +|-----------------|-------------| | `manage_contacts` | Grants the ability to create, edit, and delete contacts | -| `view_updates` | Allows access to updated contacts | +| `view_contacts` | Allows access to view contacts | --- @@ -40,8 +40,8 @@ permissions within that role. ### Example Roles | Role Name | Assigned Permissions | |-----------|----------------------| -| `admin` | `manage_contacts`, `view_updates` | -| `viewer` | `view_updates` | +| `admin` | `manage_contacts`, `view_contacts` | +| `viewer` | `view_contacts` | --- @@ -85,8 +85,7 @@ curl --oauth2-bearer /ctms?primary_email= ## Protecting Endpoints in FastAPI -To protect an API endpoint and **require a specific permission**, use the **`with_permission`** -helper from `ctms.permissions`. +This is how API endpoints are protected using the `with_permission` helper from `ctms.permissions`. ### Usage diff --git a/docs/cli.md b/docs/cli.md index 3f329aee..b1d9dedb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -9,7 +9,7 @@ This document outlines how to use the CTMS CLI to manage these entities. ## Confirmation Prompts for Destructive Actions To prevent accidental data loss, all destructive operations (delete, revoke, disable, rotate-secret) -will prompt for confirmation before proceeding. You can bypass these prompts by using the `--yes` or `-y` +will prompt for confirmation before proceeding. You can bypass these prompts by using the `--yes` or `-y` flag when running these commands, which is especially useful for scripting or automation. Example: @@ -39,7 +39,7 @@ ctms-cli permissions create "" ### Deleting a Permission Delete the permission from the database. If the permission is assigned to a **role**, the permission will need to be revoked from the **role** first. The CLI will output the commands needed to be -performed. +performed. ```sh ctms-cli permissions delete ``` From 31ded950d886294987ce0d6f98030ae67f4ad97c Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Mon, 10 Mar 2025 15:41:34 -0700 Subject: [PATCH 5/5] Update and run pre-commit on all files --- .dockerignore | 1 - .github/dependabot.yml | 2 +- .github/workflows/build.yaml | 4 ++-- .pre-commit-config.yaml | 2 +- CODE_OF_CONDUCT.md | 4 ++-- bin/update_and_install_system_packages.sh | 2 +- 6 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.dockerignore b/.dockerignore index 94c3d6b6..f4b11987 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,2 @@ .git .github - diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d14f02e4..90f482ac 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,4 +20,4 @@ updates: interval: "weekly" groups: all-dependencies: - update-types: ["major", "minor", "patch"] \ No newline at end of file + update-types: ["major", "minor", "patch"] diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a507aebc..c10d920b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -26,7 +26,7 @@ jobs: fetch-depth: 0 fetch-tags: true persist-credentials: false - + - id: determine_tag name: determine tag run: |- @@ -68,7 +68,7 @@ jobs: registry: ${{ env.GAR_LOCATION }}-docker.pkg.dev username: oauth2accesstoken password: ${{ steps.gcp_auth.outputs.access_token }} - + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50de60bf..a446c48f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-added-large-files - id: check-json diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 498baa3f..041fbb69 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,8 +1,8 @@ # Community Participation Guidelines -This repository is governed by Mozilla's code of conduct and etiquette guidelines. +This repository is governed by Mozilla's code of conduct and etiquette guidelines. For more details, please read the -[Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). +[Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). ## How to Report For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. diff --git a/bin/update_and_install_system_packages.sh b/bin/update_and_install_system_packages.sh index 4a9278e6..71d18f50 100755 --- a/bin/update_and_install_system_packages.sh +++ b/bin/update_and_install_system_packages.sh @@ -1,6 +1,6 @@ #!/bin/bash set -euo pipefail -# Tell apt-get we're never going to be able to give manual +# Tell apt-get we're never going to be able to give manual # feedback: export DEBIAN_FRONTEND=noninteractive