Skip to content
Draft
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
1 change: 1 addition & 0 deletions changes/11733.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Record the client IP address on every `login_history` entry and expose it as `clientIp` on `LoginHistoryV2` (null for system-driven events such as eviction/expiration).
11 changes: 11 additions & 0 deletions docs/manager/graphql-reference/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -8729,6 +8729,11 @@ input LoginHistoryFilter
domainName: StringFilter = null
result: LoginHistoryResultFilter = null
createdAt: DateTimeFilter = null

"""
Added in UNRELEASED. Filter by the client IP recorded on the login_history event.
"""
clientIp: StringFilter = null
AND: [LoginHistoryFilter!] = null
OR: [LoginHistoryFilter!] = null
NOT: [LoginHistoryFilter!] = null
Expand All @@ -8749,6 +8754,7 @@ enum LoginHistoryOrderField
CREATED_AT @join__enumValue(graph: STRAWBERRY)
RESULT @join__enumValue(graph: STRAWBERRY)
DOMAIN_NAME @join__enumValue(graph: STRAWBERRY)
CLIENT_IP @join__enumValue(graph: STRAWBERRY)
}

"""Added in 26.4.2. Filter for login attempt result field."""
Expand Down Expand Up @@ -8790,6 +8796,11 @@ type LoginHistoryV2 implements Node
"""Timestamp when the login attempt occurred."""
createdAt: DateTime!

"""
Added in UNRELEASED. Client IP that initiated the event. Null for system-driven events (e.g. eviction, expiration).
"""
clientIp: String

"""Added in 26.4.3. The user who attempted to log in."""
user: UserV2

Expand Down
11 changes: 11 additions & 0 deletions docs/manager/graphql-reference/v2-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -5675,6 +5675,11 @@ input LoginHistoryFilter {
domainName: StringFilter = null
result: LoginHistoryResultFilter = null
createdAt: DateTimeFilter = null

"""
Added in UNRELEASED. Filter by the client IP recorded on the login_history event.
"""
clientIp: StringFilter = null
AND: [LoginHistoryFilter!] = null
OR: [LoginHistoryFilter!] = null
NOT: [LoginHistoryFilter!] = null
Expand All @@ -5691,6 +5696,7 @@ enum LoginHistoryOrderField {
CREATED_AT
RESULT
DOMAIN_NAME
CLIENT_IP
}

"""Added in 26.4.2. Filter for login attempt result field."""
Expand Down Expand Up @@ -5727,6 +5733,11 @@ type LoginHistoryV2 implements Node {
"""Timestamp when the login attempt occurred."""
createdAt: DateTime!

"""
Added in UNRELEASED. Client IP that initiated the event. Null for system-driven events (e.g. eviction, expiration).
"""
clientIp: String

"""Added in 26.4.3. The user who attempted to log in."""
user: UserV2

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ class LoginHistoryFilter(BaseRequestModel):
created_at: DateTimeFilter | None = Field(
default=None, description="Filter history by created_at datetime"
)
client_ip: StringFilter | None = Field(
default=None, description="Client IP filter (source IP recorded on the event)"
)
AND: list[LoginHistoryFilter] | None = Field(
default=None, description="All conditions must match"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class LoginHistoryNode(BaseResponseModel):
fail_reason: str | None = Field(
default=None, description="Detailed reason for the login failure"
)
client_ip: str | None = Field(
default=None,
description="Client IP that initiated the event. Null for system-driven events (e.g. eviction, expiration).",
)
created_at: datetime = Field(description="Timestamp when the login attempt occurred")


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ class LoginHistoryOrderField(StrEnum):
CREATED_AT = "created_at"
RESULT = "result"
DOMAIN_NAME = "domain_name"
CLIENT_IP = "client_ip"
14 changes: 14 additions & 0 deletions src/ai/backend/manager/api/adapters/login_history/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ def _convert_filter(self, f: LoginHistoryFilter) -> list[QueryCondition]:
)
if condition is not None:
conditions.append(condition)
if f.client_ip is not None:
condition = self.convert_string_filter(
f.client_ip,
contains_factory=LoginHistoryConditions.by_client_ip_contains,
equals_factory=LoginHistoryConditions.by_client_ip_equals,
starts_with_factory=LoginHistoryConditions.by_client_ip_starts_with,
ends_with_factory=LoginHistoryConditions.by_client_ip_ends_with,
in_factory=LoginHistoryConditions.by_client_ip_in,
)
if condition is not None:
conditions.append(condition)
if f.AND:
for sub_filter in f.AND:
conditions.extend(self._convert_filter(sub_filter))
Expand Down Expand Up @@ -172,6 +183,8 @@ def _convert_orders(orders: list[LoginHistoryOrder]) -> list[QueryOrder]:
result.append(LoginHistoryOrders.result(ascending))
case LoginHistoryOrderField.DOMAIN_NAME:
result.append(LoginHistoryOrders.domain_name(ascending))
case LoginHistoryOrderField.CLIENT_IP:
result.append(LoginHistoryOrders.client_ip(ascending))
return result

@staticmethod
Expand All @@ -182,5 +195,6 @@ def _data_to_node(data: LoginHistoryData) -> LoginHistoryNode:
domain_name=data.domain_name,
result=LoginAttemptResult(data.result.value),
fail_reason=data.fail_reason,
client_ip=data.client_ip,
created_at=data.created_at,
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
LoginHistoryFilter,
LoginHistoryResultFilter,
)
from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION
from ai.backend.manager.api.gql.base import DateTimeFilter, StringFilter
from ai.backend.manager.api.gql.decorators import (
BackendAIGQLMeta,
gql_added_field,
gql_field,
gql_pydantic_input,
)
Expand Down Expand Up @@ -48,6 +50,13 @@ class LoginHistoryFilterGQL(PydanticInputMixin[LoginHistoryFilter]):
domain_name: StringFilter | None = None
result: LoginHistoryResultFilterGQL | None = None
created_at: DateTimeFilter | None = None
client_ip: StringFilter | None = gql_added_field(
BackendAIGQLMeta(
added_version=NEXT_RELEASE_VERSION,
description="Filter by the client IP recorded on the login_history event.",
),
default=None,
)

AND: list[Self] | None = None
OR: list[Self] | None = None
Expand Down
8 changes: 8 additions & 0 deletions src/ai/backend/manager/api/gql/login_history/types/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from strawberry.relay import Connection, Edge, NodeID

from ai.backend.common.dto.manager.v2.login_history.response import LoginHistoryNode
from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION
from ai.backend.manager.api.gql.decorators import (
BackendAIGQLMeta,
gql_added_field,
Expand Down Expand Up @@ -63,6 +64,13 @@ class LoginHistoryV2GQL(PydanticNodeMixin[LoginHistoryNode]):
fail_reason: str | None = gql_field(description="Detailed reason for the login failure.")
created_at: datetime = gql_field(description="Timestamp when the login attempt occurred.")

client_ip: str | None = gql_added_field(
BackendAIGQLMeta(
added_version=NEXT_RELEASE_VERSION,
description="Client IP that initiated the event. Null for system-driven events (e.g. eviction, expiration).",
)
)

@gql_added_field(
BackendAIGQLMeta(
added_version="26.4.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class LoginHistoryOrderFieldGQL(StrEnum):
CREATED_AT = "created_at"
RESULT = "result"
DOMAIN_NAME = "domain_name"
CLIENT_IP = "client_ip"


@gql_pydantic_input(
Expand Down
6 changes: 5 additions & 1 deletion src/ai/backend/manager/api/rest/auth/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ async def authorize(
stoken=params.stoken,
otp=params.otp,
client_type_id=params.client_type_id,
client_ip=extract_client_ip(ctx.request),
force=params.force,
)
Comment on lines 151 to 156
result = await self._auth.authorize.wait_for_complete(action)
Expand Down Expand Up @@ -179,7 +180,10 @@ async def authorize(
async def logout(self, body: BodyParam[LogoutRequest], ctx: RequestCtx) -> APIResponse:
params = body.parsed
log.info("AUTH.LOGOUT(session_token:{}...)", params.session_token[:8])
action = LogoutAction(session_token=params.session_token)
action = LogoutAction(
session_token=params.session_token,
client_ip=extract_client_ip(ctx.request),
)
await self._auth.logout.wait_for_complete(action)
return APIResponse.build(HTTPStatus.OK, LogoutResponse())

Expand Down
1 change: 1 addition & 0 deletions src/ai/backend/manager/data/auth/login_session_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ class LoginHistoryData:
domain_name: str
result: LoginAttemptResult
fail_reason: str | None
client_ip: str | None
created_at: datetime
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""add client_ip to login_history

Revision ID: a1b3e7c2d4f5
Revises: b8a85c96607c
Create Date: 2026-05-20

"""

# Part of: 26.5.0

import sqlalchemy as sa
from alembic import op

revision = "a1b3e7c2d4f5"
down_revision = "b8a85c96607c"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column(
"login_history",
sa.Column("client_ip", sa.String(length=45), nullable=True),
)


def downgrade() -> None:
op.drop_column("login_history", "client_ip")
4 changes: 4 additions & 0 deletions src/ai/backend/manager/models/login_session/row.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ class LoginHistoryRow(Base): # type: ignore[misc]
index=True,
)
fail_reason: Mapped[str | None] = mapped_column("fail_reason", sa.Text, nullable=True)
# 45 = INET6_ADDRSTRLEN - 1: the longest possible textual representation of an
# IP address is an IPv4-mapped IPv6 form like "0000:0000:0000:0000:0000:ffff:255.255.255.255".
client_ip: Mapped[str | None] = mapped_column("client_ip", sa.String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(
"created_at",
sa.DateTime(timezone=True),
Expand All @@ -126,5 +129,6 @@ def to_data(self) -> LoginHistoryData:
domain_name=self.domain_name,
result=self.result,
fail_reason=self.fail_reason,
client_ip=self.client_ip,
created_at=self.created_at,
)
24 changes: 21 additions & 3 deletions src/ai/backend/manager/repositories/auth/db_source/db_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ async def _record_login_history(
domain_name: str,
result: LoginAttemptResult,
fail_reason: str | None,
client_ip: str | None,
) -> None:
"""Insert a login history record (internal, within an existing connection)."""
await conn.execute(
Expand All @@ -379,6 +380,7 @@ async def _record_login_history(
domain_name=domain_name,
result=result,
fail_reason=fail_reason,
client_ip=client_ip,
)
)

Expand All @@ -389,6 +391,7 @@ async def record_login_history(
domain_name: str,
result: LoginAttemptResult,
fail_reason: str | None = None,
client_ip: str | None = None,
) -> None:
"""Insert a login history record (public, manages its own transaction)."""
async with self._db.begin_session() as db_session:
Expand All @@ -398,6 +401,7 @@ async def record_login_history(
domain_name=domain_name,
result=result,
fail_reason=fail_reason,
client_ip=client_ip,
)
)

Expand Down Expand Up @@ -465,6 +469,7 @@ async def delete_sessions_by_tokens(
self,
session_tokens: list[str],
result: LoginAttemptResult,
client_ip: str | None = None,
) -> None:
"""Delete the given login sessions and record history for each.

Expand All @@ -483,11 +488,12 @@ async def delete_sessions_by_tokens(
.cte("deleted")
)
insert_query = lh.insert().from_select(
["user_id", "domain_name", "result"],
["user_id", "domain_name", "result", "client_ip"],
sa.select(
deleted.c.user_id,
users.c.domain_name,
sa.literal(result.value).label("result"),
sa.literal(client_ip).label("client_ip"),
).select_from(deleted.join(users, deleted.c.user_id == users.c.uuid)),
)
await conn.execute(insert_query)
Expand All @@ -501,6 +507,7 @@ async def create_login_session(
domain_name: str,
*,
login_client_type_id: UUID | None = None,
client_ip: str | None = None,
) -> LoginSessionCreationResult:
"""Create a new active login session and record a successful login history entry.

Expand All @@ -523,7 +530,12 @@ async def create_login_session(

# Record successful login in the same transaction.
await self._record_login_history(
conn, user_id, domain_name, LoginAttemptResult.SUCCESS, fail_reason=None
conn,
user_id,
domain_name,
LoginAttemptResult.SUCCESS,
fail_reason=None,
client_ip=client_ip,
)

await conn.commit()
Expand Down Expand Up @@ -608,6 +620,7 @@ async def delete_session_by_token(
self,
session_token: str,
result: LoginAttemptResult,
client_ip: str | None = None,
) -> None:
"""Delete a single login session by its token and record history.

Expand All @@ -624,11 +637,12 @@ async def delete_session_by_token(
.cte("deleted")
)
insert_query = lh.insert().from_select(
["user_id", "domain_name", "result"],
["user_id", "domain_name", "result", "client_ip"],
sa.select(
deleted.c.user_id,
users.c.domain_name,
sa.literal(result.value).label("result"),
sa.literal(client_ip).label("client_ip"),
).select_from(deleted.join(users, deleted.c.user_id == users.c.uuid)),
)
await conn.execute(insert_query)
Expand All @@ -640,6 +654,7 @@ async def delete_sessions_by_user(
user_id: UUID,
domain_name: str,
result: LoginAttemptResult,
client_ip: str | None = None,
) -> list[str]:
"""Delete all login sessions for a user, record history, return tokens.

Expand All @@ -661,6 +676,7 @@ async def delete_sessions_by_user(
"user_id": user_id,
"domain_name": domain_name,
"result": result,
"client_ip": client_ip,
}
for _ in deleted_tokens
],
Expand Down Expand Up @@ -721,6 +737,7 @@ async def delete_session_by_id(
self,
session_id: UUID,
result: LoginAttemptResult,
client_ip: str | None = None,
) -> str:
"""Delete a login session by its ID, record history, return session_token.

Expand Down Expand Up @@ -754,6 +771,7 @@ async def delete_session_by_id(
user_id=row.user_id,
domain_name=domain_name,
result=result,
client_ip=client_ip,
)
)
await conn.commit()
Expand Down
Loading
Loading