Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,10 @@ class RoleResponse(BaseModel):

name: str
permissions: list[ActionResourceResponse] = Field(default_factory=list, serialization_alias="actions")


class RoleCollectionResponse(BaseModel):
"""Outgoing representation of a paginated collection of roles."""

roles: list[RoleResponse]
total_entries: int
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,15 @@ paths:
summary: Create Role
description: Create a new role (actions can be empty).
operationId: create_role
security:
- OAuth2PasswordBearer: []
- HTTPBearer: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoleBody'
required: true
responses:
'200':
description: Successful Response
Expand All @@ -103,44 +106,115 @@ paths:
schema:
$ref: '#/components/schemas/RoleResponse'
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
description: Bad Request
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
description: Unauthorized
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
description: Forbidden
'409':
description: Conflict
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
description: Conflict
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
description: Internal Server Error
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
get:
tags:
- FabAuthManager
summary: Get Roles
description: List roles with pagination and ordering.
operationId: get_roles
security:
- OAuth2PasswordBearer: []
- HTTPBearer: []
parameters:
- name: order_by
in: query
required: false
schema:
type: string
description: Field to order by. Prefix with '-' for descending.
default: name
title: Order By
description: Field to order by. Prefix with '-' for descending.
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
description: Number of items to skip before starting to collect results.
default: 0
title: Offset
description: Number of items to skip before starting to collect results.
- name: limit
in: query
required: false
schema:
type: integer
minimum: 0
default: 100
title: Limit
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/RoleCollectionResponse'
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
description: Bad Request
'401':
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
description: Unauthorized
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
description: Forbidden
'500':
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
description: Internal Server Error
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
components:
schemas:
ActionResourceResponse:
Expand Down Expand Up @@ -238,6 +312,22 @@ components:
- name
title: RoleBody
description: Incoming payload for creating/updating a role.
RoleCollectionResponse:
properties:
roles:
items:
$ref: '#/components/schemas/RoleResponse'
type: array
title: Roles
total_entries:
type: integer
title: Total Entries
type: object
required:
- roles
- total_entries
title: RoleCollectionResponse
description: Outgoing representation of a paginated collection of roles.
RoleResponse:
properties:
name:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations

import logging

from fastapi import Query

from airflow.configuration import conf

log = logging.getLogger(__name__)


def get_effective_limit(default: int = 100):
"""
Return a FastAPI dependency that enforces API page limit rules.

:param default: Default limit if not provided by client.
"""

def _limit(
limit: int = Query(
default,
ge=0,
),
) -> int:
max_val = conf.getint("api", "maximum_page_limit")
fallback = conf.getint("api", "fallback_page_limit")

if limit == 0:
return fallback
if limit > max_val:
log.warning(
"The limit param value %s passed in API exceeds the configured maximum page limit %s",
limit,
max_val,
)
return max_val
return limit

return _limit
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,26 @@

from typing import TYPE_CHECKING

from fastapi import Depends, status
from fastapi import Depends, Query, status

from airflow.api_fastapi.common.router import AirflowRouter
from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import RoleBody, RoleResponse
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
RoleBody,
RoleCollectionResponse,
RoleResponse,
)
from airflow.providers.fab.auth_manager.api_fastapi.parameters import get_effective_limit
from airflow.providers.fab.auth_manager.api_fastapi.security import requires_fab_custom_view
from airflow.providers.fab.auth_manager.api_fastapi.services.roles import FABAuthManagerRoles
from airflow.providers.fab.auth_manager.cli_commands.utils import get_application_builder
from airflow.providers.fab.www.security import permissions

if TYPE_CHECKING:
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import RoleBody, RoleResponse
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
RoleBody,
RoleResponse,
)


roles_router = AirflowRouter(prefix="/fab/v1", tags=["FabAuthManager"])
Expand All @@ -52,3 +60,26 @@ def create_role(body: RoleBody) -> RoleResponse:
"""Create a new role (actions can be empty)."""
with get_application_builder():
return FABAuthManagerRoles.create_role(body=body)


@roles_router.get(
"/roles",
response_model=RoleCollectionResponse,
responses=create_openapi_http_exception_doc(
[
status.HTTP_400_BAD_REQUEST,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
status.HTTP_500_INTERNAL_SERVER_ERROR,
]
),
dependencies=[Depends(requires_fab_custom_view("GET", permissions.RESOURCE_ROLE))],
)
def get_roles(
order_by: str = Query("name", description="Field to order by. Prefix with '-' for descending."),
limit: int = Depends(get_effective_limit()),
offset: int = Query(0, ge=0, description="Number of items to skip before starting to collect results."),
) -> RoleCollectionResponse:
"""List roles with pagination and ordering."""
with get_application_builder():
return FABAuthManagerRoles.get_roles(order_by=order_by, limit=limit, offset=offset)
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@
from typing import TYPE_CHECKING

from fastapi import HTTPException, status
from sqlalchemy import func, select

from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import RoleBody, RoleResponse
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
RoleBody,
RoleCollectionResponse,
RoleResponse,
)
from airflow.providers.fab.auth_manager.api_fastapi.sorting import build_ordering
from airflow.providers.fab.auth_manager.models import Role
from airflow.providers.fab.www.utils import get_fab_auth_manager

if TYPE_CHECKING:
Expand Down Expand Up @@ -72,3 +79,20 @@ def create_role(cls, body: RoleBody) -> RoleResponse:
)

return RoleResponse.model_validate(created)

@classmethod
def get_roles(cls, *, order_by: str, limit: int, offset: int) -> RoleCollectionResponse:
security_manager = get_fab_auth_manager().security_manager
session = security_manager.session

total_entries = session.scalars(select(func.count(Role.id))).one()

ordering = build_ordering(order_by, allowed={"name": Role.name, "role_id": Role.id})

stmt = select(Role).order_by(ordering).offset(offset).limit(limit)
roles = session.scalars(stmt).unique().all()

return RoleCollectionResponse(
roles=[RoleResponse.model_validate(r) for r in roles],
total_entries=total_entries,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations

from collections.abc import Mapping
from typing import TYPE_CHECKING, Any

from fastapi import HTTPException, status
from sqlalchemy import asc, desc

if TYPE_CHECKING:
from sqlalchemy.sql.elements import ColumnElement


def build_ordering(order_by: str, *, allowed: Mapping[str, ColumnElement[Any]]) -> ColumnElement[Any]:
"""
Build an SQLAlchemy ORDER BY expression from the `order_by` parameter.

:param order_by: Public field name, optionally prefixed with "-" for descending.
:param allowed: Map of public field to SQLAlchemy column/expression.
"""
is_desc = order_by.startswith("-")
key = order_by.lstrip("-")

if key not in allowed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ordering with '{order_by}' is disallowed or the attribute does not exist on the model",
)

col = allowed[key]
return desc(col) if is_desc else asc(col)
Loading