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 @@ -40,6 +40,12 @@ class ActionResource(BaseModel):
resource: Resource


class Role(BaseModel):
"""Lightweight role reference used by /users schemas."""

name: str


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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 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, SecretStr

from airflow.api_fastapi.common.types import UtcDateTime
from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import Role


class UserBody(StrictBaseModel):
"""Incoming payload for creating a user."""

username: str = Field(min_length=1)
email: str = Field(min_length=1)
first_name: str = Field(min_length=1)
last_name: str = Field(min_length=1)
roles: list[Role] | None = None
password: SecretStr


class UserResponse(BaseModel):
"""Outgoing representation of a user (no password)."""

username: str
email: str
first_name: str
last_name: str
roles: list[Role] | None = None
active: bool | None = None
last_login: UtcDateTime | None = None
login_count: int | None = None
fail_login_count: int | None = None
created_on: UtcDateTime | None = None
changed_on: UtcDateTime | None = None
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,64 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/auth/fab/v1/users:
post:
tags:
- FabAuthManager
summary: Create User
operationId: create_user
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UserBody'
required: true
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'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:
Action:
Expand Down Expand Up @@ -464,6 +522,16 @@ components:
- name
title: Resource
description: Outgoing representation of a resource.
Role:
properties:
name:
type: string
title: Name
type: object
required:
- name
title: Role
description: Lightweight role reference used by /users schemas.
RoleBody:
properties:
name:
Expand Down Expand Up @@ -512,6 +580,108 @@ components:
- name
title: RoleResponse
description: Outgoing representation of a role and its permissions.
UserBody:
properties:
username:
type: string
minLength: 1
title: Username
email:
type: string
minLength: 1
title: Email
first_name:
type: string
minLength: 1
title: First Name
last_name:
type: string
minLength: 1
title: Last Name
roles:
anyOf:
- items:
$ref: '#/components/schemas/Role'
type: array
- type: 'null'
title: Roles
password:
type: string
format: password
title: Password
writeOnly: true
additionalProperties: false
type: object
required:
- username
- email
- first_name
- last_name
- password
title: UserBody
description: Incoming payload for creating a user.
UserResponse:
properties:
username:
type: string
title: Username
email:
type: string
title: Email
first_name:
type: string
title: First Name
last_name:
type: string
title: Last Name
roles:
anyOf:
- items:
$ref: '#/components/schemas/Role'
type: array
- type: 'null'
title: Roles
active:
anyOf:
- type: boolean
- type: 'null'
title: Active
last_login:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Last Login
login_count:
anyOf:
- type: integer
- type: 'null'
title: Login Count
fail_login_count:
anyOf:
- type: integer
- type: 'null'
title: Fail Login Count
created_on:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Created On
changed_on:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Changed On
type: object
required:
- username
- email
- first_name
- last_name
title: UserResponse
description: Outgoing representation of a user (no password).
ValidationError:
properties:
loc:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 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, 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.users import UserBody, UserResponse
from airflow.providers.fab.auth_manager.api_fastapi.security import requires_fab_custom_view
from airflow.providers.fab.auth_manager.api_fastapi.services.users import FABAuthManagerUsers
from airflow.providers.fab.auth_manager.cli_commands.utils import get_application_builder
from airflow.providers.fab.www.security import permissions

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


@users_router.post(
"/users",
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_USER))],
)
def create_user(body: UserBody) -> UserResponse:
with get_application_builder():
return FABAuthManagerUsers.create_user(body=body)
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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 HTTPException, status

from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import Role
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.users import UserBody, UserResponse
from airflow.providers.fab.auth_manager.security_manager.override import FabAirflowSecurityManagerOverride
from airflow.providers.fab.www.utils import get_fab_auth_manager


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

@staticmethod
def _resolve_roles(
sm: FabAirflowSecurityManagerOverride, role_refs: list[Role] | None
) -> tuple[list, list[str]]:
seen = set()
roles: list = []
missing: list[str] = []
for r in role_refs or []:
if r.name in seen:
continue
seen.add(r.name)
role = sm.find_role(r.name)
(roles if role else missing).append(role or r.name)
return roles, missing

@classmethod
def create_user(cls, body: UserBody) -> UserResponse:
security_manager = get_fab_auth_manager().security_manager

existing_username = security_manager.find_user(username=body.username)
if existing_username:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Username `{body.username}` already exists. Use PATCH to update.",
)

existing_email = security_manager.find_user(email=body.email)
if existing_email:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=f"The email `{body.email}` is already taken."
)

roles_to_add, missing_role_names = cls._resolve_roles(security_manager, body.roles)
if missing_role_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown roles: {', '.join(repr(n) for n in missing_role_names)}",
)
if not roles_to_add:
default_role = security_manager.find_role(security_manager.auth_user_registration_role)
if default_role is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Default registration role is not configured or not found.",
)
roles_to_add.append(default_role)

created = security_manager.add_user(
username=body.username,
email=body.email,
first_name=body.first_name,
last_name=body.last_name,
role=roles_to_add,
password=body.password.get_secret_value(),
)
if not created:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add user `{body.username}`",
)

return UserResponse.model_validate(created)
Loading
Loading