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
@@ -0,0 +1,56 @@
# 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 pydantic import Field

from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel


class ActionResponse(BaseModel):
"""Outgoing representation of an action (permission name)."""

name: str


class ResourceResponse(BaseModel):
"""Outgoing representation of a resource."""

name: str


class ActionResourceResponse(BaseModel):
"""Pairing of an action with a resource."""

action: ActionResponse
resource: ResourceResponse


class RoleBody(StrictBaseModel):
"""Incoming payload for creating/updating a role."""

name: str = Field(min_length=1)
permissions: list[ActionResourceResponse] = Field(
default_factory=list, alias="actions", validation_alias="actions"
)


class RoleResponse(BaseModel):
"""Outgoing representation of a role and its permissions."""

name: str
permissions: list[ActionResourceResponse] = Field(default_factory=list, serialization_alias="actions")
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,89 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/auth/fab/v1/roles:
post:
tags:
- FabAuthManager
summary: Create Role
description: Create a new role (actions can be empty).
operationId: create_role
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RoleBody'
required: true
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/RoleResponse'
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
'409':
description: Conflict
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- OAuth2PasswordBearer: []
- HTTPBearer: []
components:
schemas:
ActionResourceResponse:
properties:
action:
$ref: '#/components/schemas/ActionResponse'
resource:
$ref: '#/components/schemas/ResourceResponse'
type: object
required:
- action
- resource
title: ActionResourceResponse
description: Pairing of an action with a resource.
ActionResponse:
properties:
name:
type: string
title: Name
type: object
required:
- name
title: ActionResponse
description: Outgoing representation of an action (permission name).
HTTPExceptionResponse:
properties:
detail:
Expand Down Expand Up @@ -130,6 +211,48 @@ components:
- access_token
title: LoginResponse
description: API Token serializer for responses.
ResourceResponse:
properties:
name:
type: string
title: Name
type: object
required:
- name
title: ResourceResponse
description: Outgoing representation of a resource.
RoleBody:
properties:
name:
type: string
minLength: 1
title: Name
actions:
items:
$ref: '#/components/schemas/ActionResourceResponse'
type: array
title: Actions
additionalProperties: false
type: object
required:
- name
title: RoleBody
description: Incoming payload for creating/updating a role.
RoleResponse:
properties:
name:
type: string
title: Name
actions:
items:
$ref: '#/components/schemas/ActionResourceResponse'
type: array
title: Actions
type: object
required:
- name
title: RoleResponse
description: Outgoing representation of a role and its permissions.
ValidationError:
properties:
loc:
Expand All @@ -151,3 +274,21 @@ components:
- msg
- type
title: ValidationError
securitySchemes:
OAuth2PasswordBearer:
type: oauth2
description: To authenticate Airflow API requests, clients must include a JWT
(JSON Web Token) in the Authorization header of each request. This token is
used to verify the identity of the client and ensure that they have the appropriate
permissions to access the requested resources. You can use the endpoint ``POST
/auth/token`` in order to generate a JWT token. Upon successful authentication,
the server will issue a JWT token that contains the necessary information
(such as user identity and scope) to authenticate subsequent requests. To
learn more about Airflow public API authentication, please read https://airflow.apache.org/docs/apache-airflow/stable/security/api.html.
flows:
password:
scopes: {}
tokenUrl: /auth/token
HTTPBearer:
type: http
scheme: bearer
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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 typing import TYPE_CHECKING

from fastapi import Depends, 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.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


roles_router = AirflowRouter(prefix="/fab/v1", tags=["FabAuthManager"])


@roles_router.post(
"/roles",
responses=create_openapi_http_exception_doc(
[
status.HTTP_400_BAD_REQUEST,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
status.HTTP_409_CONFLICT,
status.HTTP_500_INTERNAL_SERVER_ERROR,
]
),
dependencies=[Depends(requires_fab_custom_view("POST", permissions.RESOURCE_ROLE))],
)
def create_role(body: RoleBody) -> RoleResponse:
"""Create a new role (actions can be empty)."""
with get_application_builder():
return FABAuthManagerRoles.create_role(body=body)
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 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 fastapi import Depends, HTTPException, status

from airflow.api_fastapi.app import get_auth_manager
from airflow.api_fastapi.core_api.security import get_user


def requires_fab_custom_view(method: str, resource_name: str):
def _check(user=Depends(get_user)):
if not get_auth_manager().is_authorized_custom_view(
method=method, resource_name=resource_name, user=user
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")

return _check
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 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 typing import TYPE_CHECKING

from fastapi import HTTPException, status

from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import RoleBody, RoleResponse
from airflow.providers.fab.www.utils import get_fab_auth_manager

if TYPE_CHECKING:
from airflow.providers.fab.auth_manager.security_manager.override import FabAirflowSecurityManagerOverride


class FABAuthManagerRoles:
"""Service layer for FAB Auth Manager role operations (create, validate, sync)."""

@staticmethod
def _check_action_and_resource(
security_manager: FabAirflowSecurityManagerOverride,
perms: list[tuple[str, str]],
) -> None:
for action_name, resource_name in perms:
if not security_manager.get_action(action_name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"The specified action: {action_name!r} was not found",
)
if not security_manager.get_resource(resource_name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"The specified resource: {resource_name!r} was not found",
)

@classmethod
def create_role(cls, body: RoleBody) -> RoleResponse:
security_manager = get_fab_auth_manager().security_manager

existing = security_manager.find_role(name=body.name)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Role with name {body.name!r} already exists; please update with the PATCH endpoint",
)

perms: list[tuple[str, str]] = [(ar.action.name, ar.resource.name) for ar in (body.permissions or [])]

cls._check_action_and_resource(security_manager, perms)

security_manager.bulk_sync_roles([{"role": body.name, "perms": perms}])

created = security_manager.find_role(name=body.name)
if not created:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Role was not created due to an unexpected error.",
)

return RoleResponse.model_validate(created)
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ def get_fastapi_app(self) -> FastAPI | None:
from airflow.providers.fab.auth_manager.api_fastapi.routes.login import (
login_router,
)
from airflow.providers.fab.auth_manager.api_fastapi.routes.roles import roles_router

flask_app = create_app(enable_plugins=False)

Expand All @@ -241,6 +242,7 @@ def get_fastapi_app(self) -> FastAPI | None:

# Add the login router to the FastAPI app
app.include_router(login_router)
app.include_router(roles_router)

app.mount("/", WSGIMiddleware(flask_app))

Expand Down
Loading