Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
fail-fast: false
matrix:
python-version: ["3.12"]
gitlab-version: ["17.2.8-ce.0"]
gitlab-version: ["17.2.8-ce.0", "17.9.3-ce.0"]

env:
GITLAB_CE_VERSION: ${{ matrix.gitlab-version }}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Add User and Groups Model

Revision ID: 5ae180bb248c
Revises: 00ee97d0b7a3
Create Date: 2025-03-26 07:30:50.314010+00:00

"""

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "5ae180bb248c"
down_revision = "00ee97d0b7a3"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"foxops_group",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("system_name", sa.String(), nullable=False),
sa.Column("display_name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("system_name"),
)
op.create_table(
"foxops_user",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("username", sa.String(), nullable=False),
sa.Column("is_admin", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("username"),
)
op.create_table(
"group_user",
sa.Column("group_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["group_id"], ["foxops_group.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["foxops_user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("group_id", "user_id"),
)
op.create_table(
"group_incarnation_permission",
sa.Column("group_id", sa.Integer(), nullable=False),
sa.Column("incarnation_id", sa.Integer(), nullable=False),
sa.Column("type", sa.Enum("READ", "WRITE", name="permission"), nullable=False),
sa.ForeignKeyConstraint(["group_id"], ["foxops_group.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["incarnation_id"], ["incarnation.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("group_id", "incarnation_id"),
)
op.create_table(
"user_incarnation_permission",
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("incarnation_id", sa.Integer(), nullable=False),
sa.Column("type", sa.Enum("READ", "WRITE", name="permission"), nullable=False),
sa.ForeignKeyConstraint(["incarnation_id"], ["incarnation.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["foxops_user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id", "incarnation_id"),
)
with op.batch_alter_table("change") as batch_op:
batch_op.add_column(sa.Column("initialized_by", sa.Integer(), nullable=True))
batch_op.create_foreign_key("change_user_fk", "foxops_user", ["initialized_by"], ["id"], ondelete="SET NULL")

with op.batch_alter_table("incarnation") as batch_op:
batch_op.add_column(sa.Column("owner", sa.Integer(), nullable=True))
batch_op.create_foreign_key("incarnation_owner_fk", "foxops_user", ["owner"], ["id"], ondelete="NO ACTION")

op.execute("INSERT INTO foxops_user (id, username, is_admin) VALUES (1, 'root', TRUE)")
op.execute("UPDATE incarnation SET owner = 1")

with op.batch_alter_table("incarnation") as batch_op:
batch_op.alter_column("owner", nullable=False)

op.create_index(
"ix_group_permission_incarnation_id", "group_incarnation_permission", ["incarnation_id"], unique=False
)
op.create_index(
"ix_user_permission_incarnation_id", "user_incarnation_permission", ["incarnation_id"], unique=False
)

op.create_index("ix_user_username", "foxops_user", ["username"])
op.create_index("ix_group_system_name", "foxops_group", ["system_name"])


def downgrade() -> None:
op.drop_constraint("change_user_fk", "incarnation", type_="foreignkey")
op.drop_column("incarnation", "owner")
op.drop_constraint("incarnation_owner_fk", "change", type_="foreignkey")
op.drop_column("change", "initialized_by")
op.drop_table("user_incarnation_permission")
op.drop_table("group_incarnation_permission")
op.drop_table("group_user")
op.drop_table("foxops_user")
op.drop_table("foxops_group")
op.drop_index("ix_group_permission_incarnation_id", table_name="group_incarnation_permission")
op.drop_index("ix_user_permission_incarnation_id", table_name="user_incarnation_permission")
1 change: 1 addition & 0 deletions docs/source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ tutorials/index
:caption: References

reference/templates
reference/authorization
terminology
api
```
Expand Down
113 changes: 113 additions & 0 deletions docs/source/reference/authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Authorization
FoxOps supports an authorization system that introduces the concept of users and groups. This allows for limiting access to certain incarnations based on the user or group.

```{admonition} Missing Authentification
:class: caution
The current authorization system doesn't yet check if the provided users or groups are valid. Additionally, the current static API token is shared between all users, making it technically possible to access all incarnations with the API token.

This can currently be fixed by using an external component to authenticate a request. While this is a limitation, it is planned to integrate FoxOps with an external SSO service, which would remove this limitation.
```

Currently, each incoming request expects three different headers

| Header | Description | Required |
| ----- | ----------- | -------- |
| `Authorization` | The static API token which is used to authenticate the request. This token can be set with the `FOXOPS_STATIC_TOKEN` environment variable. | ✓ |
| `User` | The user which is used to authenticate the request. This user is checked to see if they have access to the requested incarnation. | ✓ |
| `Groups` | The groups which are used to authenticate the request. These groups are checked to see if the user has access to the requested incarnation. This header can also be provided empty or omitted. | X |

## User/Group Creation
User and groups get automatically created when a request is made to the API. This means that if a user or group doesn't exist, it will be created automatically. The newly create users don't have admin rights by default.

However there exists a default user in the database (`root`), which has admin rights. This user is created automatically when the database is initialized.


## Basic Authorization Concepts
As previously mentioned, FoxOps uses the provided HTTP headers (`User` and `Groups`) to authorize incoming requests.

### User
A FoxOps user is uniquely identified by its username. It is also the username that can be used to authorize an incoming request.

### Groups
Groups in FoxOps are uniquely identified by their system name. This system name can differ from the display name. A group can have zero or more users.


### Incarnation
An Incarnation always has a User as an owner. This user is permitted to perform all actions on the Incarnation. It is also possible to assign different users or groups privileges on the Incarnation. These privileges can either be read or write (which also includes read) permissions.


### Change
The change model now also stores which user initialized said change. However, it is not possible to give certain groups or users permissions on a change. The change permission is managed by the Incarnation to which the change belongs.


### Access Control
The following endpoints are protected with an authorization layer, which requires certain rights.

```{admonition} Access Control
Users which have admin rights are allowed to perform any actions on any endpoints.
```

| Endpoint | Method | Required Permission |
| -------- | ------ | ------------------- |
| `/incarnations` | GET | only returns the Incarnations, to which the User has either read permission or is the owner |
| `/incarnations/{id}` | GET | read or is the owner |
| `/incarnations` | POST | No permissions required. However the current user automatically becomes the owner of the newly created Incarnation. |
| `/incarnations/{id}` | PUT | write or is the owner |
| `/incarnations/{id}` | DELETE | write or is the owner |
| `/incarnations/{id}` | PATCH | write or is the owner |
| `/incarnations/{id}/reset` | POST | write or is the owner |
| `/incarnations/{id}/diff` | GET | read or is the owner |
| `/incarnations/{id}/changes` | GET | read or is the owner |
| `/incarnations/{id}/changes` | POST | write or is the owner |
| `/incarnations/{id}/changes/{revision}` | GET | read or is the owner |
| `/incarnations/{id}/changes/{revision}/fix` | POST | write or is the owner |
| `/users` | GET | Admin only |
| `/users/{id}` | GET | Admin only |
| `/users/{id}` | PATCH | Admin only |
| `/users/{id}` | DELETE | Admin only |
| `/groups` | GET | Admin only |
| `/groups/{id}` | GET | Admin only |
| `/groups/{id}` | PATCH | Admin only |
| `/groups/{id}` | DELETE | Admin only |

## Productive Setup
While the FoxOps API currently doesn't authenticate the User and Groups headers, it is still possible to use this setup in a productive environment. An example of such a setup is described below.

The following mermaid diagram describes the setup of FoxOps with an external SSO service and a reverse proxy. The reverse proxy is used to authenticate the user and add the User and Groups headers to the request. This ensures that the provided user and groups are valid.

```{mermaid}
graph TD
A[Client/User]
B[SSO Service]
C[Reverse Proxy]
D[FoxOps API]

A --> |1. Login and request JWT| B
B --> |2. JWT| A
A --> |3. Request FoxOps API with JWT| C
C --> |4. Validates JWT| C
C --> |5. Request FoxOps API with Headers| D
D --> |6. Response| C
C --> |6. Response| A
```

### 1. Login and request JWT
The first step in this setup is to request a new JWT token from the SSO service. An example of such a service would be [Keycloak](https://www.keycloak.org/).

### 2. JWT
The SSO service then responds with a signed JWT token. This token contains the user and group information.

### 3. Request FoxOps API with JWT
The client then requests the FoxOps API with the JWT token. This token is used to authenticate the user and groups. The client does **not** provide any of the required headers for the FoxOps API.

### 4. Validates JWT
The reverse proxy then validates the JWT signature. It parses the user and group information from the JWT token.

### 5. Request FoxOps API with Headers
The reverse proxy then requests the FoxOps API. It provides the three required headers:
* Authorization: _Static API Token configured in the Reverse Proxy_
* User: _User from the JWT token_
* Groups: _Groups from the JWT token_

### 6. Response
The API uses the provided user and groups to check if the user has access to the requested incarnation. If the user has access, the API returns the response to the reverse proxy. The reverse proxy then returns the response to the client.
27 changes: 23 additions & 4 deletions src/foxops/__main__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
from fastapi import APIRouter, Depends, FastAPI
from fastapi import APIRouter, Depends, FastAPI, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse

from foxops.dependencies import get_settings, static_token_auth_scheme
from foxops.dependencies import authorization, get_settings
from foxops.error_handlers import __error_handlers__
from foxops.logger import get_logger, setup_logging
from foxops.middlewares import request_id_middleware, request_time_middleware
from foxops.models.errors import ApiError
from foxops.openapi import custom_openapi
from foxops.routers import auth, incarnations, not_found, version
from foxops.routers import auth, group, incarnations, not_found, user, version

#: Holds the module logger instance
logger = get_logger(__name__)
Expand Down Expand Up @@ -46,8 +47,26 @@ def create_app():
public_router.include_router(auth.router)

# Add routes to the protected router (authentication required)
protected_router = APIRouter(dependencies=[Depends(static_token_auth_scheme)])
protected_router = APIRouter(
dependencies=[Depends(authorization)],
responses={
status.HTTP_401_UNAUTHORIZED: {
"description": (
"The provided API Token is invalid or missing. "
"You have to provide a valid API Token in the format 'Bearer \\<token\\>'."
),
"model": ApiError,
},
status.HTTP_403_FORBIDDEN: {
"description": "You are not allowed to access the requested resource or perform the requested action.",
"model": ApiError,
},
},
)

protected_router.include_router(incarnations.router)
protected_router.include_router(user.router)
protected_router.include_router(group.router)

app.include_router(public_router)
app.include_router(protected_router)
Expand Down
35 changes: 35 additions & 0 deletions src/foxops/authz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from fastapi import Depends

from foxops.dependencies import authorization, get_incarnation_service
from foxops.errors import ResourceForbiddenError
from foxops.services.authorization import AuthorizationService
from foxops.services.incarnation import IncarnationService


async def read_access_on_incarnation(
incarnation_id: int,
authorization_service: AuthorizationService = Depends(authorization),
incarnation_service: IncarnationService = Depends(get_incarnation_service),
) -> None:
permissions = await incarnation_service.get_permissions(incarnation_id)

if not authorization_service.has_read_access(permissions):
raise ResourceForbiddenError


async def write_access_on_incarnation(
incarnation_id: int,
authorization_service: AuthorizationService = Depends(authorization),
incarnation_service: IncarnationService = Depends(get_incarnation_service),
) -> None:
permissions = await incarnation_service.get_permissions(incarnation_id)

if not authorization_service.has_write_access(permissions):
raise ResourceForbiddenError


async def access_to_admin_only(
authorization_service: AuthorizationService = Depends(authorization),
) -> None:
if not authorization_service.current_user.is_admin:
raise ResourceForbiddenError("Only administrators are allowed to access this endpoint")
7 changes: 7 additions & 0 deletions src/foxops/database/repositories/change/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class ChangeInDB(BaseModel):

merge_request_id: str | None
merge_request_branch_name: str | None
initialized_by: int | None

model_config = ConfigDict(from_attributes=True)

@classmethod
Expand All @@ -55,4 +57,9 @@ class IncarnationWithChangesSummary(BaseModel):
requested_version: str
merge_request_id: str | None
created_at: datetime

owner_id: int
owner_username: str
owner_is_admin: bool

model_config = ConfigDict(from_attributes=True)
Loading