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/.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Track the originating client IP on active login sessions, expose it as `clientIp` on `LoginSessionV2` and the v2 REST `LoginSessionNode`, and allow filtering/sorting active sessions by `client_ip`.
11 changes: 11 additions & 0 deletions docs/manager/graphql-reference/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -8842,6 +8842,11 @@ input LoginSessionFilter
accessKey: StringFilter = null
createdAt: DateTimeFilter = null
lastAccessedAt: DateTimeFilter = null

"""
Added in UNRELEASED. Filter by the originating client IP recorded on the session.
"""
clientIp: StringFilter = null
AND: [LoginSessionFilter!] = null
OR: [LoginSessionFilter!] = null
NOT: [LoginSessionFilter!] = null
Expand All @@ -8862,6 +8867,7 @@ enum LoginSessionOrderField
CREATED_AT @join__enumValue(graph: STRAWBERRY)
STATUS @join__enumValue(graph: STRAWBERRY)
LAST_ACCESSED_AT @join__enumValue(graph: STRAWBERRY)
CLIENT_IP @join__enumValue(graph: STRAWBERRY)
}

"""Added in 26.4.2. Status of a login session."""
Expand Down Expand Up @@ -8915,6 +8921,11 @@ type LoginSessionV2 implements Node
"""Timestamp when the session was invalidated."""
invalidatedAt: DateTime

"""
Added in UNRELEASED. Originating client IP of the login that created this session, if known.
"""
clientIp: String

"""Added in 26.4.3. The user who owns this login session."""
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 @@ -5773,6 +5773,11 @@ input LoginSessionFilter {
accessKey: StringFilter = null
createdAt: DateTimeFilter = null
lastAccessedAt: DateTimeFilter = null

"""
Added in UNRELEASED. Filter by the originating client IP recorded on the session.
"""
clientIp: StringFilter = null
AND: [LoginSessionFilter!] = null
OR: [LoginSessionFilter!] = null
NOT: [LoginSessionFilter!] = null
Expand All @@ -5789,6 +5794,7 @@ enum LoginSessionOrderField {
CREATED_AT
STATUS
LAST_ACCESSED_AT
CLIENT_IP
}

"""Added in 26.4.2. Status of a login session."""
Expand Down Expand Up @@ -5835,6 +5841,11 @@ type LoginSessionV2 implements Node {
"""Timestamp when the session was invalidated."""
invalidatedAt: DateTime

"""
Added in UNRELEASED. Originating client IP of the login that created this session, if known.
"""
clientIp: String

"""Added in 26.4.3. The user who owns this login session."""
user: UserV2
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ class LoginSessionFilter(BaseRequestModel):
last_accessed_at: DateTimeFilter | None = Field(
default=None, description="Filter sessions by last_accessed_at datetime"
)
client_ip: StringFilter | None = Field(
default=None, description="Client IP filter (origin IP recorded on the session)"
)
AND: list[LoginSessionFilter] | None = Field(
default=None, description="All conditions must match"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ class LoginSessionNode(BaseResponseModel):
invalidated_at: datetime | None = Field(
default=None, description="Timestamp when the session was invalidated"
)
client_ip: str | None = Field(
default=None,
description="Originating client IP of the login that created this session, if known",
)


class AdminSearchLoginSessionsPayload(BaseResponseModel):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ class LoginSessionOrderField(StrEnum):
CREATED_AT = "created_at"
STATUS = "status"
LAST_ACCESSED_AT = "last_accessed_at"
CLIENT_IP = "client_ip"
14 changes: 14 additions & 0 deletions src/ai/backend/manager/api/adapters/login_session/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,17 @@ def _convert_filter(self, f: LoginSessionFilter) -> 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=LoginSessionConditions.by_client_ip_contains,
equals_factory=LoginSessionConditions.by_client_ip_equals,
starts_with_factory=LoginSessionConditions.by_client_ip_starts_with,
ends_with_factory=LoginSessionConditions.by_client_ip_ends_with,
in_factory=LoginSessionConditions.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 @@ -226,6 +237,8 @@ def _convert_orders(orders: list[LoginSessionOrder]) -> list[QueryOrder]:
result.append(LoginSessionOrders.status(ascending))
case LoginSessionOrderField.LAST_ACCESSED_AT:
result.append(LoginSessionOrders.last_accessed_at(ascending))
case LoginSessionOrderField.CLIENT_IP:
result.append(LoginSessionOrders.client_ip(ascending))
return result

@staticmethod
Expand All @@ -238,4 +251,5 @@ def _data_to_node(data: LoginSessionData) -> LoginSessionNode:
created_at=data.created_at,
last_accessed_at=data.last_accessed_at,
invalidated_at=data.invalidated_at,
client_ip=data.client_ip,
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
LoginSessionFilter,
LoginSessionStatusFilter,
)
from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION
from ai.backend.manager.api.gql.base import DateTimeFilter, StringFilter, UUIDFilter
from ai.backend.manager.api.gql.decorators import (
BackendAIGQLMeta,
gql_added_field,
gql_field,
gql_pydantic_input,
)
Expand Down Expand Up @@ -50,6 +52,13 @@ class LoginSessionFilterGQL(PydanticInputMixin[LoginSessionFilter]):
access_key: StringFilter | None = None
created_at: DateTimeFilter | None = None
last_accessed_at: DateTimeFilter | None = None
client_ip: StringFilter | None = gql_added_field(
BackendAIGQLMeta(
added_version=NEXT_RELEASE_VERSION,
description="Filter by the originating client IP recorded on the session.",
),
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_session/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_session.response import LoginSessionNode
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 @@ -57,6 +58,13 @@ class LoginSessionV2GQL(PydanticNodeMixin[LoginSessionNode]):
invalidated_at: datetime | None = gql_field(
description="Timestamp when the session was invalidated."
)
client_ip: str | None = gql_added_field(
BackendAIGQLMeta(
added_version=NEXT_RELEASE_VERSION,
description="Originating client IP of the login that created this session, if known.",
),
default=None,
)

@gql_added_field(
BackendAIGQLMeta(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class LoginSessionOrderFieldGQL(StrEnum):
CREATED_AT = "created_at"
STATUS = "status"
LAST_ACCESSED_AT = "last_accessed_at"
CLIENT_IP = "client_ip"


@gql_pydantic_input(
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 @@ -17,6 +17,7 @@ class LoginSessionData:
created_at: datetime
last_accessed_at: datetime | None
invalidated_at: datetime | None
client_ip: str | None


@dataclass(frozen=True)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""add client_ip to login_sessions

Revision ID: c4d2f8b6e9a1
Revises: a1b3e7c2d4f5
Create Date: 2026-05-22

"""

# Part of: 26.5.0

import sqlalchemy as sa
from alembic import op

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


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


def downgrade() -> None:
op.drop_column("login_sessions", "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 @@ -64,6 +64,9 @@ class LoginSessionRow(Base): # type: ignore[misc]
invalidated_at: Mapped[datetime | None] = mapped_column(
"invalidated_at", sa.DateTime(timezone=True), 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)

__table_args__ = (sa.Index("ix_login_sessions_user_id_status", "user_id", "status"),)

Expand All @@ -81,6 +84,7 @@ def to_data(self) -> LoginSessionData:
created_at=self.created_at,
last_accessed_at=self.last_accessed_at,
invalidated_at=self.invalidated_at,
client_ip=self.client_ip,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ async def create_login_session(
session_token=session_token,
status=LoginSessionStatus.ACTIVE,
login_client_type_id=login_client_type_id,
client_ip=client_ip,
)
)

Expand Down
62 changes: 62 additions & 0 deletions src/ai/backend/manager/repositories/auth/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,62 @@ def inner() -> sa.sql.expression.ColumnElement[bool]:

by_access_key_in = staticmethod(make_string_in_factory(LoginSessionRow.access_key))

# --- client_ip string filters ---

@staticmethod
def by_client_ip_contains(spec: StringMatchSpec) -> QueryCondition:
def inner() -> sa.sql.expression.ColumnElement[bool]:
if spec.case_insensitive:
condition = LoginSessionRow.client_ip.ilike(f"%{spec.value}%")
else:
condition = LoginSessionRow.client_ip.like(f"%{spec.value}%")
if spec.negated:
condition = sa.not_(condition)
return condition

return inner

@staticmethod
def by_client_ip_equals(spec: StringMatchSpec) -> QueryCondition:
def inner() -> sa.sql.expression.ColumnElement[bool]:
if spec.case_insensitive:
condition = sa.func.lower(LoginSessionRow.client_ip) == spec.value.lower()
else:
condition = LoginSessionRow.client_ip == spec.value
if spec.negated:
condition = sa.not_(condition)
return condition

return inner

@staticmethod
def by_client_ip_starts_with(spec: StringMatchSpec) -> QueryCondition:
def inner() -> sa.sql.expression.ColumnElement[bool]:
if spec.case_insensitive:
condition = LoginSessionRow.client_ip.ilike(f"{spec.value}%")
else:
condition = LoginSessionRow.client_ip.like(f"{spec.value}%")
if spec.negated:
condition = sa.not_(condition)
return condition

return inner

@staticmethod
def by_client_ip_ends_with(spec: StringMatchSpec) -> QueryCondition:
def inner() -> sa.sql.expression.ColumnElement[bool]:
if spec.case_insensitive:
condition = LoginSessionRow.client_ip.ilike(f"%{spec.value}")
else:
condition = LoginSessionRow.client_ip.like(f"%{spec.value}")
if spec.negated:
condition = sa.not_(condition)
return condition

return inner

by_client_ip_in = staticmethod(make_string_in_factory(LoginSessionRow.client_ip))

# --- created_at datetime filters ---

@staticmethod
Expand Down Expand Up @@ -225,6 +281,12 @@ def last_accessed_at(ascending: bool = True) -> QueryOrder:
return LoginSessionRow.last_accessed_at.asc()
return LoginSessionRow.last_accessed_at.desc()

@staticmethod
def client_ip(ascending: bool = True) -> QueryOrder:
if ascending:
return LoginSessionRow.client_ip.asc()
return LoginSessionRow.client_ip.desc()


class LoginHistoryConditions:
"""Query conditions for login history."""
Expand Down
Loading
Loading