Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NAS-131396 / 25.04 / Add support for user-linked API keys #14578

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions src/freenas/etc/pam.d/common-account-unix
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
account requisite pam_deny.so
account required pam_permit.so
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Convert to user-linked tokens
Revision ID: 8ae49ac78d14
Revises: 6dedf12c1035
Create Date: 2024-09-19 18:48:55.972115+00:00
"""
from alembic import op
import sqlalchemy as sa
import json


# revision identifiers, used by Alembic.
revision = '8ae49ac78d14'
down_revision = '6dedf12c1035'
branch_labels = None
depends_on = None

DEFAULT_ALLOW_LIST = [{"method": "*", "resource": "*"}]
ENTRY_REVOKED = -1


def upgrade():
conn = op.get_bind()
to_revoke = []
for row in conn.execute("SELECT id, allowlist FROM account_api_key").fetchall():
try:
if json.loads(row['allowlist']) != DEFAULT_ALLOW_LIST:
to_revoke.append(str(row['id']))
except Exception:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we log it somewhere ?

to_revoke.append(str(row['id']))

with op.batch_alter_table('account_api_key', schema=None) as batch_op:
batch_op.add_column(sa.Column('user_identifier', sa.String(length=200), nullable=False, server_default='LEGACY_API_KEY'))
batch_op.add_column(sa.Column('expiry', sa.Integer(), nullable=False, server_default='0'))
batch_op.drop_column('allowlist')

conn.execute(f"UPDATE account_api_key SET expiry={ENTRY_REVOKED} WHERE id IN ({', '.join(to_revoke)});")


def downgrade():
pass
3 changes: 2 additions & 1 deletion src/middlewared/middlewared/api/base/server/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
import uuid

from middlewared.auth import SessionManagerCredentials
from middlewared.auth import SessionManagerCredentials, AuthenticationContext
from middlewared.utils.origin import ConnectionOrigin

logger = logging.getLogger(__name__)
Expand All @@ -12,6 +12,7 @@ def __init__(self, origin: ConnectionOrigin):
self.origin = origin
self.session_id = str(uuid.uuid4())
self.authenticated = False
self.authentication_context: AuthenticationContext = AuthenticationContext()
self.authenticated_credentials: SessionManagerCredentials | None = None
self.py_exceptions = False
self.websocket = False
Expand Down
25 changes: 23 additions & 2 deletions src/middlewared/middlewared/api/v25_04_0/api_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

from pydantic import Secret, StringConstraints

from middlewared.api.base import BaseModel, Excluded, excluded_field, ForUpdateMetaclass, NonEmptyString
from middlewared.api.base import (
BaseModel, Excluded, excluded_field, ForUpdateMetaclass, NonEmptyString,
LocalUsername, RemoteUsername
)


HttpVerb: TypeAlias = Literal["GET", "POST", "PUT", "DELETE", "CALL", "SUBSCRIBE", "*"]
Expand All @@ -18,8 +21,13 @@ class AllowListItem(BaseModel):
class ApiKeyEntry(BaseModel):
id: int
name: Annotated[NonEmptyString, StringConstraints(max_length=200)]
username: LocalUsername | RemoteUsername
user_identifier: int | str
keyhash: Secret[str]
created_at: datetime
allowlist: list[AllowListItem]
expires_at: datetime | None = None
local: bool
revoked: bool


class ApiKeyEntryWithKey(ApiKeyEntry):
Expand All @@ -28,7 +36,11 @@ class ApiKeyEntryWithKey(ApiKeyEntry):

class ApiKeyCreate(ApiKeyEntry):
id: Excluded = excluded_field()
user_identifier: Excluded = excluded_field()
keyhash: Excluded = excluded_field()
created_at: Excluded = excluded_field()
local: Excluded = excluded_field()
revoked: Excluded = excluded_field()


class ApiKeyCreateArgs(BaseModel):
Expand All @@ -40,6 +52,7 @@ class ApiKeyCreateResult(BaseModel):


class ApiKeyUpdate(ApiKeyCreate, metaclass=ForUpdateMetaclass):
username: Excluded = excluded_field()
reset: bool


Expand All @@ -58,3 +71,11 @@ class ApiKeyDeleteArgs(BaseModel):

class ApiKeyDeleteResult(BaseModel):
result: Literal[True]


class ApiKeyMyKeysArgs(BaseModel):
pass


class ApiKeyMyKeysResult(BaseModel):
result: list[ApiKeyEntry]
75 changes: 73 additions & 2 deletions src/middlewared/middlewared/api/v25_04_0/auth.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,85 @@
from middlewared.api.base import BaseModel, single_argument_result
from middlewared.utils.auth import AuthMech, AuthResp
from pydantic import Field, Secret
from typing import Literal
from .user import UserGetUserObjResult


class AuthMeArgs(BaseModel):
pass


@single_argument_result
class AuthMeResult(UserGetUserObjResult.model_fields["result"].annotation):
class AuthUserInfo(UserGetUserObjResult.model_fields["result"].annotation):
attributes: dict
two_factor_config: dict
privilege: dict
account_attributes: list[str]


@single_argument_result
class AuthMeResult(AuthUserInfo):
pass


class AuthCommonOptions(BaseModel):
user_info: bool = True # include auth.me in successful result


class AuthApiKeyPlain(BaseModel):
mechanism: Literal[AuthMech.API_KEY_PLAIN.name]
username: str
api_key: Secret[str]
login_options: AuthCommonOptions = Field(default=AuthCommonOptions())


class AuthPasswordPlain(BaseModel):
mechanism: Literal[AuthMech.PASSWORD_PLAIN.name]
username: str
password: Secret[str]
login_options: AuthCommonOptions = Field(default=AuthCommonOptions())


class AuthTokenPlain(BaseModel):
mechanism: Literal[AuthMech.TOKEN_PLAIN.name]
token: Secret[str]
login_options: AuthCommonOptions = Field(default=AuthCommonOptions())


class AuthOTPToken(BaseModel):
mechanism: Literal[AuthMech.OTP_TOKEN.name]
otp_token: Secret[str]
login_options: AuthCommonOptions = Field(default=AuthCommonOptions())


class AuthRespSuccess(BaseModel):
response_type: Literal[AuthResp.SUCCESS.name]
user_info: AuthUserInfo | None


class AuthRespAuthErr(BaseModel):
response_type: Literal[AuthResp.AUTH_ERR.name]


class AuthRespExpired(BaseModel):
response_type: Literal[AuthResp.EXPIRED.name]


class AuthRespOTPRequired(BaseModel):
response_type: Literal[AuthResp.OTP_REQUIRED.name]
username: str


class AuthLoginExArgs(BaseModel):
login_data: AuthApiKeyPlain | AuthPasswordPlain | AuthTokenPlain | AuthOTPToken


class AuthLoginExResult(BaseModel):
result: AuthRespSuccess | AuthRespAuthErr | AuthRespExpired | AuthRespOTPRequired


class AuthMechChoicesArgs(BaseModel):
pass


class AuthMechChoicesResult(BaseModel):
result: list[str]
2 changes: 2 additions & 0 deletions src/middlewared/middlewared/api/v25_04_0/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class UserEntry(BaseModel):
twofactor_auth_configured: bool
sid: str | None
roles: list[str]
api_keys: list[int]


class UserCreate(UserEntry):
Expand All @@ -70,6 +71,7 @@ class UserCreate(UserEntry):
twofactor_auth_configured: Excluded = excluded_field()
sid: Excluded = excluded_field()
roles: Excluded = excluded_field()
api_keys: Excluded = excluded_field()

uid: LocalUID | None = None
"UNIX UID. If not provided, it is automatically filled with the next one available."
Expand Down
Loading
Loading