mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-14 07:42:28 +00:00
feat: add an authorization API (#4363)
Add an authorization API to the LanceDB catalog. This is an API that can be used to bind access control lists (ACLs) to individual catalog objects. It can also be used to manage server-side roles, and introspect principals and groups. The new API is implemented in both the Rust, Python, and async Python styles and includes list_acls, add_acl, delete_acl, list_role_bindings, add_role_binding, delete_role_binding, get_principal, and get_group. This API implements the design principle of forwards-compatibility. So unknown privilege, object, and subject types do not cause errors, but are be supported as best as possible in the library.
This commit is contained in:
Generated
+1
@@ -5779,6 +5779,7 @@ dependencies = [
|
||||
"serial_test",
|
||||
"sha2 0.10.9",
|
||||
"snafu 0.8.9",
|
||||
"strum_macros 0.28.0",
|
||||
"tempfile",
|
||||
"test-log",
|
||||
"tokenizers",
|
||||
|
||||
@@ -48,6 +48,169 @@ are ordinary connections. Dropping a database requires it to be empty.
|
||||
::: lancedb.catalog.DatabaseNames
|
||||
|
||||
|
||||
## Authorization
|
||||
|
||||
Some catalogs support the authorization API. This is an API that can be used to
|
||||
bind access control lists (ACLs) to individual catalog objects.
|
||||
|
||||
Each ACL contains a subject, an object, and privilege.
|
||||
|
||||
### Subjects
|
||||
|
||||
There are three kinds of subjects: principals, groups, and roles.
|
||||
|
||||
A principal uniquely identifies each user accessing the system. Each LanceDB
|
||||
Enterprise API key corresponds to a unique principal, and each OpenID user has
|
||||
a unique principal.
|
||||
|
||||
All principals have an immutable ID which never changes, and is safe to include
|
||||
in log files. For example, the API key
|
||||
`sk_AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYPQ====` maps to the
|
||||
principal ID `pk_aaaqeayeaudaocajbifqydiob4`. APIs such as `list_acls` will
|
||||
return the principal ID, never the API key.
|
||||
|
||||
OpenID principals also typically have a human-readable name associated with
|
||||
them. For example, a principal might be named `bob@example.com` and might have
|
||||
a principal ID of `8a004400-9443-4fab-9997-b9372a7ca8fd`. Whenever
|
||||
possible, APIs will return the principal name for convenience. However,
|
||||
principal names are mutable and can be changed by the identity provider.
|
||||
|
||||
Here is an example of getting information about an API key:
|
||||
```python
|
||||
from lancedb.authz import Subject
|
||||
|
||||
catalog = lancedb.connect_catalog("https://catalog.example.com", api_key=api_key)
|
||||
principal = catalog.authz.get_principal(Subject.principal_api_key(api_key))
|
||||
print(f"Found principal {principal.principal_id}")
|
||||
```
|
||||
|
||||
OpenID principals can be members of groups. Like principals, groups also have
|
||||
IDs and human-readable names. It is not possible to modify group membership via
|
||||
this API; it is managed by the OpenID identity provider.
|
||||
|
||||
Here is an example of getting information about a group based on its name:
|
||||
```python
|
||||
from lancedb.authz import Subject
|
||||
|
||||
catalog = lancedb.connect_catalog("https://catalog.example.com", api_key=api_key)
|
||||
group = catalog.authz.get_group(Subject.group_name("Engineering"))
|
||||
print(f"Found group {group.group_id} with name {group.group_name}")
|
||||
```
|
||||
|
||||
Roles can contain either principals or groups. Unlike groups, they are managed
|
||||
by the catalog, not the identity provider. Roles can also contain API key
|
||||
principals.
|
||||
|
||||
Here is an example of managing the `reader` role:
|
||||
```python
|
||||
from lancedb.authz import Subject
|
||||
|
||||
catalog = lancedb.connect_catalog("https://catalog.example.com", api_key=api_key)
|
||||
catalog.authz.add_role_binding(subject=Subject.principal_name("Alice"), role="reader")
|
||||
```
|
||||
It may take a few milliseconds for changes to role bindings to become visible
|
||||
to all clients.
|
||||
|
||||
More subject types may be added in the future.
|
||||
|
||||
### Objects
|
||||
|
||||
An object is any resource in the system that we can attach authorization rules to.
|
||||
The Python builders and Rust client share resource validation and wire encoding.
|
||||
Methods also accept literal strings such as `table:tenant/db:public$events`;
|
||||
malformed names for known resource types raise `ValueError` locally before a
|
||||
request is sent. Unknown resource types are preserved for compatibility with
|
||||
newer servers.
|
||||
|
||||
Object types include:
|
||||
|
||||
The System object, a singleton which represents the entire system. You can
|
||||
refer to it as `Object.system()`.
|
||||
|
||||
Database objects which represents specific databases. For example,
|
||||
`Object.database("analytics")` represents the "analytics" database.
|
||||
|
||||
Table objects which represent specific tables. For example,
|
||||
`Object.table(database="analytics", name="events")` represents the "events"
|
||||
table inside the "analytics" database. Tables are in the default namespace (which
|
||||
is named "public") unless a specific namespace is given.
|
||||
|
||||
Similarly, View objects represent specific views. For example,
|
||||
`Object.view(database="analytics", name="recent_events")` represents the
|
||||
"recent_events" view inside the "analytics" database. Views are in the default
|
||||
namespace (which is named "public") unless a specific namespace is given.
|
||||
|
||||
Secret objects identify named credentials, for example,
|
||||
`Object.secret(database="analytics", name="api-key")`. Function objects identify
|
||||
registered functions, for example,
|
||||
`Object.function(database="analytics", name="caption")`. Both default to the
|
||||
`public` namespace. Pass `namespace_path=["platform", "ml"]` to select a nested
|
||||
namespace. These objects identify resources for ACL operations; a secret object
|
||||
contains the credential's name, not its value.
|
||||
|
||||
More object types may be added in the future.
|
||||
|
||||
### Access control lists
|
||||
|
||||
An access control list attaches a specific set of privileges to a specific
|
||||
(subject, object) pair. So, for example, we might have an access control list
|
||||
that gives `bob@example.com` the ability to read the foo table, that looks like
|
||||
this:
|
||||
|
||||
```python
|
||||
from lancedb.authz import Object, Subject, Privilege
|
||||
|
||||
catalog = lancedb.connect_catalog("https://catalog.example.com", api_key=api_key)
|
||||
catalog.authz.add_acl(
|
||||
object=Object.table(database="analytics", name="foo"),
|
||||
subject=Subject.principal_name("bob@example.com"),
|
||||
privilege=Privilege.SELECT,
|
||||
)
|
||||
```
|
||||
|
||||
Reading a table generally requires USAGE on its database and namespace, plus
|
||||
SELECT on the table. Each object has at most one owner.
|
||||
|
||||
OWNERSHIP is a special privilege which grants all the other privileges. By
|
||||
default, the principal who created an object owns it. Only one subject can own
|
||||
an object; granting OWNERSHIP to a new subject removes it from the previous
|
||||
owner. OWNERSHIP cannot be deleted.
|
||||
|
||||
To delegate secret or function creation, grant `CREATE_SECRET` or
|
||||
`CREATE_FUNCTION` on the containing namespace. This preserves namespace ownership:
|
||||
|
||||
```python
|
||||
for privilege in [Privilege.CREATE_SECRET, Privilege.CREATE_FUNCTION]:
|
||||
catalog.authz.add_acl(
|
||||
object=Object.namespace(database="analytics", namespace_path=["public"]),
|
||||
subject=Subject.role("developer"),
|
||||
privilege=privilege,
|
||||
)
|
||||
```
|
||||
|
||||
Both privileges also accept their string names in synchronous and asynchronous
|
||||
ACL methods.
|
||||
|
||||
By default, the `list_acls` function lists all the access control entries in
|
||||
the system. By adding a function parameter, the output is filtered to just ACLs
|
||||
matching that parameter. For example, this code lists all the ACLs for the
|
||||
"reader" role:
|
||||
|
||||
```python
|
||||
from lancedb.authz import Subject
|
||||
|
||||
catalog = lancedb.connect_catalog("https://catalog.example.com", api_key=api_key)
|
||||
for entry in catalog.authz.list_acls(subject=Subject.role("reader")):
|
||||
print(entry)
|
||||
```
|
||||
|
||||
Or, when using the async API:
|
||||
```python
|
||||
catalog = await lancedb.connect_catalog_async(endpoint, api_key=api_key)
|
||||
async for entry in catalog.authz.list_acls(subject=Subject.role("reader")):
|
||||
print(entry)
|
||||
```
|
||||
|
||||
## Remote SQL
|
||||
|
||||
Submit SQL against a remote LanceDB database through the connection.
|
||||
@@ -361,6 +524,8 @@ not return partial results.
|
||||
|
||||
## Exceptions
|
||||
|
||||
::: lancedb.remote.errors.HttpError
|
||||
|
||||
::: lancedb.exceptions.MissingValueError
|
||||
|
||||
::: lancedb.exceptions.MissingColumnError
|
||||
|
||||
@@ -266,8 +266,41 @@ describe("remote catalog", () => {
|
||||
"limit",
|
||||
);
|
||||
}
|
||||
await expect(catalog.connectDatabase("a$b")).rejects.toThrow(
|
||||
"Invalid database name",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"/db",
|
||||
"db/",
|
||||
"a//b",
|
||||
"a/./b",
|
||||
"a/../b",
|
||||
"a b",
|
||||
"a:b",
|
||||
"a$b",
|
||||
"a%b",
|
||||
"a?b",
|
||||
"a#b",
|
||||
"a\\b",
|
||||
"café",
|
||||
"a\nb",
|
||||
])(
|
||||
"rejects invalid database name %j before sending requests",
|
||||
async (name) => {
|
||||
await withCatalog([], async (catalog, requests) => {
|
||||
for (const method of [
|
||||
"createDatabase",
|
||||
"connectDatabase",
|
||||
"dropDatabase",
|
||||
] as const) {
|
||||
await expect(catalog[method](name)).rejects.toThrow(
|
||||
"Invalid database name",
|
||||
);
|
||||
}
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -955,6 +955,8 @@ class PermutationReader:
|
||||
def __init__(self, base_table: Table, permutation_table: Table): ...
|
||||
|
||||
class Catalog:
|
||||
@property
|
||||
def authz(self) -> AuthorizationClient: ...
|
||||
@property
|
||||
def uri(self) -> str: ...
|
||||
async def create_database(
|
||||
@@ -1001,3 +1003,60 @@ class JobListing:
|
||||
def page_token(self) -> Optional[str]: ...
|
||||
def __aiter__(self) -> JobListing: ...
|
||||
async def __anext__(self) -> JobInfo: ...
|
||||
|
||||
class AuthorizationClient:
|
||||
async def list_acls(self, request: str) -> str: ...
|
||||
async def add_acl(self, request: str) -> None: ...
|
||||
async def delete_acl(self, request: str) -> None: ...
|
||||
async def list_role_bindings(self, request: str) -> str: ...
|
||||
async def add_role_binding(self, request: str) -> None: ...
|
||||
async def delete_role_binding(self, request: str) -> None: ...
|
||||
async def get_principal(self, request: str) -> str: ...
|
||||
async def get_group(self, request: str) -> str: ...
|
||||
|
||||
class AuthzSubject:
|
||||
def __init__(self, kind: str, value: str): ...
|
||||
@staticmethod
|
||||
def from_canonical(value: str) -> AuthzSubject: ...
|
||||
@property
|
||||
def kind(self) -> str: ...
|
||||
@property
|
||||
def value(self) -> str: ...
|
||||
@property
|
||||
def display_value(self) -> str: ...
|
||||
def to_wire(self, context: Optional[str] = None) -> str: ...
|
||||
def __str__(self) -> str: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class AuthzPrivilege:
|
||||
def __init__(self, value: str): ...
|
||||
@staticmethod
|
||||
def known() -> list[str]: ...
|
||||
def __str__(self) -> str: ...
|
||||
|
||||
class AuthzObject:
|
||||
def __init__(self, value: str): ...
|
||||
@staticmethod
|
||||
def system() -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def database(name: str) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def namespace(database: str, namespace_path: Sequence[str]) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def table(
|
||||
database: str, namespace_path: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def view(
|
||||
database: str, namespace_path: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def secret(
|
||||
database: str, namespace_path: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def function(
|
||||
database: str, namespace_path: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
def __str__(self) -> str: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
"""Deployment-wide authorization through ``catalog.authz``.
|
||||
|
||||
ACLs grant one privilege on an object to a principal, group, or role. Role
|
||||
bindings grant a role to a principal or group. Identity-provider group membership
|
||||
is read-only. Name resolution and permission checks are performed by the server.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import json
|
||||
from typing import Literal, Optional, TypeVar, Union, cast
|
||||
|
||||
from . import _lancedb
|
||||
from .background_loop import LOOP
|
||||
|
||||
__all__ = [
|
||||
"Authorization",
|
||||
"AsyncAuthorization",
|
||||
"Subject",
|
||||
"Object",
|
||||
"Privilege",
|
||||
"AccessControlEntry",
|
||||
"RoleBinding",
|
||||
"IdentityInfo",
|
||||
"PrincipalInfo",
|
||||
"GroupInfo",
|
||||
]
|
||||
|
||||
_PAGE_SIZE = 100
|
||||
_T = TypeVar("_T")
|
||||
_SubjectKind = Literal[
|
||||
"principal_id",
|
||||
"principal_name",
|
||||
"group_id",
|
||||
"group_name",
|
||||
"role",
|
||||
"principal_api_key",
|
||||
"unknown",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Subject:
|
||||
"""An immutable identity selector. Prefer the named factory methods.
|
||||
|
||||
IDs are stable; names must resolve unambiguously on the server. API-key
|
||||
selectors identify the subject of an operation, not its authenticated caller.
|
||||
Secret values are omitted from representations. Unknown canonical subject
|
||||
kinds returned by newer servers use ``kind="unknown"`` and preserve the full
|
||||
wire string in ``value``; they can be passed back to ACL methods.
|
||||
"""
|
||||
|
||||
kind: _SubjectKind
|
||||
_value: str = field(repr=False)
|
||||
|
||||
_inner: _lancedb.AuthzSubject = field(init=False, repr=False, compare=False)
|
||||
|
||||
def __post_init__(self):
|
||||
object.__setattr__(
|
||||
self, "_inner", _lancedb.AuthzSubject(self.kind, self._value)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def principal_id(cls, id: str) -> "Subject":
|
||||
"""Select a principal by canonical identity-provider ID."""
|
||||
return cls("principal_id", id)
|
||||
|
||||
@classmethod
|
||||
def principal_name(cls, name: str) -> "Subject":
|
||||
"""Select a principal by an unambiguous provider name."""
|
||||
return cls("principal_name", name)
|
||||
|
||||
@classmethod
|
||||
def group_id(cls, id: str) -> "Subject":
|
||||
"""Select a group by canonical identity-provider ID."""
|
||||
return cls("group_id", id)
|
||||
|
||||
@classmethod
|
||||
def group_name(cls, name: str) -> "Subject":
|
||||
"""Select a group by an unambiguous provider name."""
|
||||
return cls("group_name", name)
|
||||
|
||||
@classmethod
|
||||
def role(cls, name: str) -> "Subject":
|
||||
"""Select a LanceDB-managed role, without an ``r:`` prefix."""
|
||||
return cls("role", name)
|
||||
|
||||
@classmethod
|
||||
def principal_api_key(cls, api_key: str) -> "Subject":
|
||||
"""Select the principal holding this secret API key."""
|
||||
return cls("principal_api_key", api_key)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
value = self._inner.display_value
|
||||
return f"Subject.{self.kind}({value!r})"
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
"""The selected value, or full wire string for unknown subject kinds.
|
||||
|
||||
API-key values are secrets.
|
||||
"""
|
||||
return self._value
|
||||
|
||||
def __reduce__(self):
|
||||
return type(self), (self.kind, self._value)
|
||||
|
||||
def _wire(self) -> str:
|
||||
return self._inner.to_wire()
|
||||
|
||||
@classmethod
|
||||
def _canonical(cls, value: str) -> "Subject":
|
||||
inner = _lancedb.AuthzSubject.from_canonical(value)
|
||||
return cls(cast(_SubjectKind, inner.kind), inner.value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Object:
|
||||
"""An immutable resource name in the authorization syntax.
|
||||
|
||||
Use the builders to avoid interpreting separators as part of a name.
|
||||
Methods also accept raw object strings, validated by the same Rust parser.
|
||||
Unknown resource types are preserved for compatibility with newer servers.
|
||||
Omitted or empty namespace paths select the database's public namespace.
|
||||
"""
|
||||
|
||||
_value: str
|
||||
_inner: _lancedb.AuthzObject = field(init=False, repr=False, compare=False)
|
||||
|
||||
def __post_init__(self):
|
||||
object.__setattr__(self, "_inner", _lancedb.AuthzObject(self._value))
|
||||
|
||||
@classmethod
|
||||
def system(cls) -> "Object":
|
||||
"""The deployment-wide system object."""
|
||||
return cls(str(_lancedb.AuthzObject.system()))
|
||||
|
||||
@classmethod
|
||||
def database(cls, name: str) -> "Object":
|
||||
"""A database; slash-containing names remain one logical database."""
|
||||
return cls(str(_lancedb.AuthzObject.database(name)))
|
||||
|
||||
@classmethod
|
||||
def namespace(
|
||||
cls, *, database: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A namespace path, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.namespace(database, namespace_path)))
|
||||
|
||||
@classmethod
|
||||
def table(
|
||||
cls, *, database: str, name: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A table, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.table(database, namespace_path, name)))
|
||||
|
||||
@classmethod
|
||||
def view(
|
||||
cls, *, database: str, name: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A view, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.view(database, namespace_path, name)))
|
||||
|
||||
@classmethod
|
||||
def secret(
|
||||
cls, *, database: str, name: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A secret, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.secret(database, namespace_path, name)))
|
||||
|
||||
@classmethod
|
||||
def function(
|
||||
cls, *, database: str, name: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A function, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.function(database, namespace_path, name)))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self._inner)
|
||||
|
||||
def __reduce__(self):
|
||||
return type(self), (self._value,)
|
||||
|
||||
|
||||
class Privilege(str, Enum):
|
||||
"""Known privileges, validated and serialized by Rust.
|
||||
|
||||
ACL methods also accept strings such as ``"SELECT"`` and future privilege
|
||||
names such as ``"PRIVILEGE20"``. Future names are preserved verbatim;
|
||||
their meaning and validity are determined by the server.
|
||||
|
||||
Grant ``CREATE_SECRET`` or ``CREATE_FUNCTION`` on a namespace to delegate
|
||||
creation of those resources without transferring namespace ownership.
|
||||
"""
|
||||
|
||||
def __new__(cls, value: str):
|
||||
canonical = str(_lancedb.AuthzPrivilege(value))
|
||||
member = str.__new__(cls, canonical)
|
||||
member._value_ = canonical
|
||||
return member
|
||||
|
||||
OWNERSHIP = "OWNERSHIP"
|
||||
USAGE = "USAGE"
|
||||
SELECT = "SELECT"
|
||||
INSERT = "INSERT"
|
||||
UPDATE = "UPDATE"
|
||||
DELETE = "DELETE"
|
||||
CREATE_DATABASE = "CREATE_DATABASE"
|
||||
CREATE_NAMESPACE = "CREATE_NAMESPACE"
|
||||
CREATE_TABLE = "CREATE_TABLE"
|
||||
CREATE_MATERIALIZED_VIEW = "CREATE_MATERIALIZED_VIEW"
|
||||
CREATE_VIEW = "CREATE_VIEW"
|
||||
CREATE_SECRET = "CREATE_SECRET"
|
||||
CREATE_FUNCTION = "CREATE_FUNCTION"
|
||||
OPERATE = "OPERATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccessControlEntry:
|
||||
"""One object/subject/privilege grant; subject contains a canonical ID or role."""
|
||||
|
||||
object: str
|
||||
subject: Subject
|
||||
subject_name: Optional[str]
|
||||
privilege: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleBinding:
|
||||
"""A direct principal/group role binding, not an expanded effective role."""
|
||||
|
||||
subject: Subject
|
||||
subject_name: Optional[str]
|
||||
role: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityInfo:
|
||||
"""A canonical identity-provider ID and its optional display name."""
|
||||
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PrincipalInfo:
|
||||
"""A principal's groups and effective roles, including group-inherited roles."""
|
||||
|
||||
principal_id: str
|
||||
principal_name: Optional[str]
|
||||
groups: list[IdentityInfo]
|
||||
roles: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroupInfo:
|
||||
"""A group's provider-managed members and LanceDB-managed direct roles."""
|
||||
|
||||
group_id: str
|
||||
group_name: Optional[str]
|
||||
principals: list[IdentityInfo]
|
||||
roles: list[str]
|
||||
|
||||
|
||||
def _subject(subject: Subject, context: Optional[str] = None) -> str:
|
||||
if not isinstance(subject, Subject):
|
||||
raise ValueError("subject must be a Subject selector")
|
||||
return subject._inner.to_wire(context)
|
||||
|
||||
|
||||
def _object(value: Union[str, Object]) -> str:
|
||||
if not isinstance(value, (str, Object)) or not str(value):
|
||||
raise ValueError("object must be a nonempty string or Object")
|
||||
return str(value) if isinstance(value, Object) else str(_lancedb.AuthzObject(value))
|
||||
|
||||
|
||||
def _limit(limit: Optional[int]) -> None:
|
||||
if limit is not None and (
|
||||
isinstance(limit, bool) or not isinstance(limit, int) or limit < 0
|
||||
):
|
||||
raise ValueError("limit must be a nonnegative integer or None")
|
||||
|
||||
|
||||
def _privilege(value: Union[str, Privilege]) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("privilege must be a string or Privilege")
|
||||
return str(_lancedb.AuthzPrivilege(value))
|
||||
|
||||
|
||||
def _entry(value: dict) -> AccessControlEntry:
|
||||
return AccessControlEntry(
|
||||
value["object"],
|
||||
Subject._canonical(value["subject_id"]),
|
||||
value.get("subject_name"),
|
||||
value["privilege"],
|
||||
)
|
||||
|
||||
|
||||
def _binding(value: dict) -> RoleBinding:
|
||||
return RoleBinding(
|
||||
Subject._canonical(value["subject_id"]),
|
||||
value.get("subject_name"),
|
||||
value["role"],
|
||||
)
|
||||
|
||||
|
||||
class AsyncAuthorization:
|
||||
"""Async authorization returned by ``AsyncCatalog.authz``.
|
||||
|
||||
HTTP failures raise [HttpError][lancedb.remote.errors.HttpError], preserving
|
||||
status and request ID. Invalid local arguments raise ValueError. Lists are
|
||||
lazy async iterators: failures can occur during iteration after some results
|
||||
have been yielded. Iteration does not provide a consistent snapshot.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: _lancedb.AuthorizationClient):
|
||||
self._inner = inner
|
||||
|
||||
async def _pages(self, method, request: dict, key: str, limit: Optional[int]):
|
||||
yielded = 0
|
||||
while limit is None or yielded < limit:
|
||||
request["max_results"] = (
|
||||
_PAGE_SIZE if limit is None else min(_PAGE_SIZE, limit - yielded)
|
||||
)
|
||||
response = json.loads(await method(json.dumps(request)))
|
||||
for value in response[key]:
|
||||
yield value
|
||||
yielded += 1
|
||||
if limit is not None and yielded >= limit:
|
||||
return
|
||||
token = response.get("continuation_token")
|
||||
if not token:
|
||||
return
|
||||
if token == request.get("continuation_token"):
|
||||
raise RuntimeError(
|
||||
"Authorization listing returned a repeated continuation token"
|
||||
)
|
||||
request["continuation_token"] = token
|
||||
|
||||
def list_acls(
|
||||
self,
|
||||
*,
|
||||
object: Optional[Union[str, Object]] = None,
|
||||
subject: Optional[Subject] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AsyncIterator[AccessControlEntry]:
|
||||
"""Iterate grants, optionally filtered by exact object and/or subject.
|
||||
|
||||
Requires USAGE on the supplied object, or on system if object is omitted.
|
||||
``limit`` bounds yielded entries across all pages; None lists all and zero
|
||||
makes no requests. Missing matches produce an empty iterator.
|
||||
"""
|
||||
_limit(limit)
|
||||
request = {}
|
||||
if object is not None:
|
||||
request["object"] = _object(object)
|
||||
if subject is not None:
|
||||
request["subject"] = _subject(subject)
|
||||
|
||||
async def entries():
|
||||
async for value in self._pages(
|
||||
self._inner.list_acls, request, "entries", limit
|
||||
):
|
||||
yield _entry(value)
|
||||
|
||||
return entries()
|
||||
|
||||
async def add_acl(
|
||||
self,
|
||||
*,
|
||||
object: Union[str, Object],
|
||||
subject: Subject,
|
||||
privilege: Union[str, Privilege],
|
||||
) -> None:
|
||||
"""Grant one privilege; requires OWNERSHIP on the object.
|
||||
|
||||
A duplicate grant raises HTTP 409. Granting OWNERSHIP transfers ownership.
|
||||
Writes are not automatically retried after ambiguous failures.
|
||||
"""
|
||||
await self._inner.add_acl(
|
||||
json.dumps(
|
||||
{
|
||||
"object": _object(object),
|
||||
"subject": _subject(subject),
|
||||
"privilege": _privilege(privilege),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_acl(
|
||||
self,
|
||||
*,
|
||||
object: Union[str, Object],
|
||||
subject: Subject,
|
||||
privilege: Union[str, Privilege],
|
||||
) -> None:
|
||||
"""Revoke one privilege; requires OWNERSHIP on the object.
|
||||
|
||||
A missing grant raises HTTP 404. OWNERSHIP itself cannot be deleted.
|
||||
"""
|
||||
await self._inner.delete_acl(
|
||||
json.dumps(
|
||||
{
|
||||
"object": _object(object),
|
||||
"subject": _subject(subject),
|
||||
"privilege": _privilege(privilege),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def list_role_bindings(
|
||||
self,
|
||||
*,
|
||||
subject: Optional[Subject] = None,
|
||||
role: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AsyncIterator[RoleBinding]:
|
||||
"""Iterate direct bindings, optionally filtered by principal/group and role.
|
||||
|
||||
Requires USAGE on system. Limit semantics match ``list_acls``. Principals'
|
||||
roles inherited through groups are available through ``get_principal``.
|
||||
"""
|
||||
_limit(limit)
|
||||
request = {}
|
||||
if subject is not None:
|
||||
request["subject"] = _subject(subject, "role_member")
|
||||
if role is not None:
|
||||
request["role"] = role
|
||||
|
||||
async def bindings():
|
||||
async for value in self._pages(
|
||||
self._inner.list_role_bindings, request, "bindings", limit
|
||||
):
|
||||
yield _binding(value)
|
||||
|
||||
return bindings()
|
||||
|
||||
async def add_role_binding(self, *, subject: Subject, role: str) -> None:
|
||||
"""Grant a role idempotently; requires OPERATE on system."""
|
||||
await self._inner.add_role_binding(
|
||||
json.dumps({"subject": _subject(subject, "role_member"), "role": role})
|
||||
)
|
||||
|
||||
async def delete_role_binding(self, *, subject: Subject, role: str) -> None:
|
||||
"""Idempotently revoke a role; requires OPERATE on system."""
|
||||
await self._inner.delete_role_binding(
|
||||
json.dumps({"subject": _subject(subject, "role_member"), "role": role})
|
||||
)
|
||||
|
||||
async def get_principal(self, subject: Subject) -> PrincipalInfo:
|
||||
"""Look up groups and effective roles; requires USAGE on system.
|
||||
|
||||
Select by ID, name, or API key. Missing/ambiguous identities raise HTTP
|
||||
404/409; unavailable or unconfigured resolution raises HTTP 503/501.
|
||||
"""
|
||||
value = json.loads(
|
||||
await self._inner.get_principal(
|
||||
json.dumps({"principal": _subject(subject, "principal")})
|
||||
)
|
||||
)
|
||||
return PrincipalInfo(
|
||||
value["principal_id"],
|
||||
value.get("principal_name"),
|
||||
[IdentityInfo(**group) for group in value["groups"]],
|
||||
value["roles"],
|
||||
)
|
||||
|
||||
async def get_group(self, subject: Subject) -> GroupInfo:
|
||||
"""Look up a group's members and roles by ID/name; requires USAGE on system.
|
||||
|
||||
Membership is read-only and comes from the identity provider. Lookup
|
||||
errors have the same HTTP semantics as ``get_principal``.
|
||||
"""
|
||||
value = json.loads(
|
||||
await self._inner.get_group(
|
||||
json.dumps({"group": _subject(subject, "group")})
|
||||
)
|
||||
)
|
||||
return GroupInfo(
|
||||
value["group_id"],
|
||||
value.get("group_name"),
|
||||
[IdentityInfo(**principal) for principal in value["principals"]],
|
||||
value["roles"],
|
||||
)
|
||||
|
||||
|
||||
async def _next(iterator: AsyncIterator[_T]) -> _T:
|
||||
return await iterator.__anext__()
|
||||
|
||||
|
||||
def _sync_iter(iterator: AsyncIterator[_T]) -> Iterator[_T]:
|
||||
try:
|
||||
while True:
|
||||
yield LOOP.run(_next(iterator))
|
||||
except StopAsyncIteration:
|
||||
return
|
||||
finally:
|
||||
close = getattr(iterator, "aclose", None)
|
||||
if close is not None:
|
||||
LOOP.run(close())
|
||||
|
||||
|
||||
class Authorization:
|
||||
"""Synchronous authorization returned by ``Catalog.authz``.
|
||||
|
||||
Shares the async API's permissions, errors, and lazy pagination behavior.
|
||||
Group membership comes from the identity provider and cannot be edited here.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: AsyncAuthorization):
|
||||
self._inner = inner
|
||||
|
||||
def list_acls(
|
||||
self,
|
||||
*,
|
||||
object: Optional[Union[str, Object]] = None,
|
||||
subject: Optional[Subject] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[AccessControlEntry]:
|
||||
"""Iterate grants.
|
||||
|
||||
See [list_acls][lancedb.authz.AsyncAuthorization.list_acls] for permissions.
|
||||
"""
|
||||
return _sync_iter(
|
||||
self._inner.list_acls(object=object, subject=subject, limit=limit)
|
||||
)
|
||||
|
||||
def add_acl(
|
||||
self,
|
||||
*,
|
||||
object: Union[str, Object],
|
||||
subject: Subject,
|
||||
privilege: Union[str, Privilege],
|
||||
) -> None:
|
||||
"""Grant a privilege; duplicates raise HTTP 409.
|
||||
|
||||
OWNERSHIP transfers ownership.
|
||||
"""
|
||||
LOOP.run(
|
||||
self._inner.add_acl(object=object, subject=subject, privilege=privilege)
|
||||
)
|
||||
|
||||
def delete_acl(
|
||||
self,
|
||||
*,
|
||||
object: Union[str, Object],
|
||||
subject: Subject,
|
||||
privilege: Union[str, Privilege],
|
||||
) -> None:
|
||||
"""Revoke a privilege. Missing grants raise HTTP 404.
|
||||
|
||||
OWNERSHIP cannot be deleted.
|
||||
"""
|
||||
LOOP.run(
|
||||
self._inner.delete_acl(object=object, subject=subject, privilege=privilege)
|
||||
)
|
||||
|
||||
def list_role_bindings(
|
||||
self,
|
||||
*,
|
||||
subject: Optional[Subject] = None,
|
||||
role: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[RoleBinding]:
|
||||
"""Iterate direct role bindings, using the same limit semantics as list_acls."""
|
||||
return _sync_iter(
|
||||
self._inner.list_role_bindings(subject=subject, role=role, limit=limit)
|
||||
)
|
||||
|
||||
def add_role_binding(self, *, subject: Subject, role: str) -> None:
|
||||
"""Grant a role idempotently; requires OPERATE on system."""
|
||||
LOOP.run(self._inner.add_role_binding(subject=subject, role=role))
|
||||
|
||||
def delete_role_binding(self, *, subject: Subject, role: str) -> None:
|
||||
"""Idempotently revoke a role; requires OPERATE on system."""
|
||||
LOOP.run(self._inner.delete_role_binding(subject=subject, role=role))
|
||||
|
||||
def get_principal(self, subject: Subject) -> PrincipalInfo:
|
||||
"""Look up groups and effective roles; requires USAGE on system."""
|
||||
return LOOP.run(self._inner.get_principal(subject))
|
||||
|
||||
def get_group(self, subject: Subject) -> GroupInfo:
|
||||
"""Look up a group's members and roles; requires USAGE on system."""
|
||||
return LOOP.run(self._inner.get_group(subject))
|
||||
@@ -3,11 +3,13 @@
|
||||
|
||||
"""Remote catalogs manage databases through a server's root namespace."""
|
||||
|
||||
from functools import cached_property
|
||||
from datetime import timedelta
|
||||
from typing import Any, AsyncIterator, Iterator, Optional, Union
|
||||
|
||||
from . import _lancedb
|
||||
from .background_loop import LOOP
|
||||
from .authz import AsyncAuthorization, Authorization
|
||||
from .db import AsyncConnection, DBConnection
|
||||
from .remote import ClientConfig, OAuthConfig
|
||||
from .remote.db import RemoteDBConnection
|
||||
@@ -86,6 +88,11 @@ class AsyncCatalog:
|
||||
def __init__(self, inner: _lancedb.Catalog):
|
||||
self._inner = inner
|
||||
|
||||
@cached_property
|
||||
def authz(self) -> AsyncAuthorization:
|
||||
"""Authorization sharing this catalog's scope and credentials."""
|
||||
return AsyncAuthorization(self._inner.authz)
|
||||
|
||||
@property
|
||||
def uri(self) -> str:
|
||||
"""The catalog's root namespace endpoint."""
|
||||
@@ -157,6 +164,11 @@ class Catalog:
|
||||
self._sql_host_override = sql_host_override
|
||||
self._oauth_config = oauth_config
|
||||
|
||||
@cached_property
|
||||
def authz(self) -> Authorization:
|
||||
"""Authorization sharing this catalog's scope and credentials."""
|
||||
return Authorization(self._inner.authz)
|
||||
|
||||
@property
|
||||
def uri(self) -> str:
|
||||
"""The catalog's root namespace endpoint."""
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import asyncio
|
||||
from dataclasses import FrozenInstanceError
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
import lancedb
|
||||
from lancedb.authz import (
|
||||
AccessControlEntry,
|
||||
AsyncAuthorization,
|
||||
Authorization,
|
||||
GroupInfo,
|
||||
IdentityInfo,
|
||||
Object,
|
||||
PrincipalInfo,
|
||||
Privilege,
|
||||
RoleBinding,
|
||||
Subject,
|
||||
)
|
||||
from lancedb.remote import HeaderProvider
|
||||
from lancedb.remote.errors import HttpError
|
||||
from test_catalog import catalog_server as _catalog_server
|
||||
|
||||
catalog_server = _catalog_server
|
||||
|
||||
|
||||
class DynamicHeaders(HeaderProvider):
|
||||
def get_headers(self):
|
||||
return {
|
||||
"Authorization": "Bearer rotating-token",
|
||||
"X-LanceDB-Database": "wrong-dynamic-db",
|
||||
"X-LanceDB-Database-Prefix": "wrong-prefix",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
def test_authz_operations_and_scope(catalog_server, asynchronous):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.extend(
|
||||
[
|
||||
(200, {}),
|
||||
(200, {}),
|
||||
(200, {}),
|
||||
(200, {}),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"subject_id": "g:team-id",
|
||||
"object": "table:tenant/db:a$b$events",
|
||||
"privilege": "PRIVILEGE20",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"bindings": [
|
||||
{
|
||||
"subject_id": "p:alice-id",
|
||||
"subject_name": "Alice",
|
||||
"role": "reader",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"principal_id": "alice-id",
|
||||
"groups": [{"id": "team-id"}],
|
||||
"roles": ["reader"],
|
||||
},
|
||||
),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"group_id": "team-id",
|
||||
"group_name": "Engineering",
|
||||
"principals": [{"id": "alice-id", "name": "Alice"}],
|
||||
"roles": ["reader"],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
config = {
|
||||
"header_provider": DynamicHeaders(),
|
||||
"extra_headers": {"x-lancedb-database": "wrong-static-db"},
|
||||
}
|
||||
obj = Object.table(database="tenant/db", namespace_path=["a", "b"], name="events")
|
||||
|
||||
async def exercise():
|
||||
if asynchronous:
|
||||
catalog = await lancedb.connect_catalog_async(
|
||||
endpoint, client_config=config
|
||||
)
|
||||
else:
|
||||
catalog = lancedb.connect_catalog(endpoint, client_config=config)
|
||||
authz = catalog.authz
|
||||
assert authz is catalog.authz
|
||||
assert requests == [] # Constructing handles does not probe a database.
|
||||
|
||||
async def call(method, *args, **kwargs):
|
||||
value = method(*args, **kwargs)
|
||||
return await value if asynchronous else value
|
||||
|
||||
await call(
|
||||
authz.add_acl,
|
||||
object=obj,
|
||||
subject=Subject.group_name("Engineering"),
|
||||
privilege=Privilege.SELECT,
|
||||
)
|
||||
await call(
|
||||
authz.delete_acl,
|
||||
object=obj,
|
||||
subject=Subject.group_id("team-id"),
|
||||
privilege="SELECT",
|
||||
)
|
||||
await call(
|
||||
authz.add_role_binding,
|
||||
subject=Subject.principal_name("Alice"),
|
||||
role="reader",
|
||||
)
|
||||
await call(
|
||||
authz.delete_role_binding,
|
||||
subject=Subject.principal_api_key("subject-secret"),
|
||||
role="reader",
|
||||
)
|
||||
iterator = authz.list_acls(object=obj, subject=Subject.role("reader"))
|
||||
entries = (
|
||||
[entry async for entry in iterator] if asynchronous else list(iterator)
|
||||
)
|
||||
assert entries == [
|
||||
AccessControlEntry(
|
||||
str(obj), Subject.group_id("team-id"), None, "PRIVILEGE20"
|
||||
)
|
||||
]
|
||||
iterator = authz.list_role_bindings(
|
||||
subject=Subject.group_id("team-id"), role="reader"
|
||||
)
|
||||
bindings = (
|
||||
[binding async for binding in iterator] if asynchronous else list(iterator)
|
||||
)
|
||||
assert bindings == [
|
||||
RoleBinding(Subject.principal_id("alice-id"), "Alice", "reader")
|
||||
]
|
||||
assert await call(
|
||||
authz.get_principal, Subject.principal_id("alice-id")
|
||||
) == PrincipalInfo("alice-id", None, [IdentityInfo("team-id")], ["reader"])
|
||||
assert await call(
|
||||
authz.get_group, Subject.group_name("Engineering")
|
||||
) == GroupInfo(
|
||||
"team-id", "Engineering", [IdentityInfo("alice-id", "Alice")], ["reader"]
|
||||
)
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert [path for path, _, _ in requests] == [
|
||||
"/admin/authz/acl/add",
|
||||
"/admin/authz/acl/delete",
|
||||
"/admin/authz/role_binding/add",
|
||||
"/admin/authz/role_binding/delete",
|
||||
"/admin/authz/acl/list",
|
||||
"/admin/authz/role_binding/list",
|
||||
"/admin/authz/principal",
|
||||
"/admin/authz/group",
|
||||
]
|
||||
assert [body for _, _, body in requests] == [
|
||||
{"object": str(obj), "subject": "G:Engineering", "privilege": "SELECT"},
|
||||
{"object": str(obj), "subject": "g:team-id", "privilege": "SELECT"},
|
||||
{"subject": "P:Alice", "role": "reader"},
|
||||
{"subject": "a:subject-secret", "role": "reader"},
|
||||
{"object": str(obj), "subject": "r:reader", "max_results": 100},
|
||||
{"subject": "g:team-id", "role": "reader", "max_results": 100},
|
||||
{"principal": "p:alice-id"},
|
||||
{"group": "G:Engineering"},
|
||||
]
|
||||
for _, headers, _ in requests:
|
||||
headers = {key.lower(): value for key, value in headers.items()}
|
||||
assert "x-lancedb-database" not in headers
|
||||
assert "x-lancedb-database-prefix" not in headers
|
||||
assert headers["authorization"] == "Bearer rotating-token"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize("limit", [None, 0, 1, 3])
|
||||
def test_lazy_expanded_acl_pagination(catalog_server, asynchronous, limit):
|
||||
endpoint, requests, responses = catalog_server
|
||||
|
||||
def entry(privilege):
|
||||
return {
|
||||
"object": "database:db",
|
||||
"subject_id": "r:reader",
|
||||
"privilege": privilege,
|
||||
}
|
||||
|
||||
responses.extend(
|
||||
[
|
||||
(
|
||||
200,
|
||||
{
|
||||
"entries": [entry("SELECT"), entry("INSERT")],
|
||||
"continuation_token": "opaque/first",
|
||||
},
|
||||
),
|
||||
(200, {"entries": [], "continuation_token": "opaque/second"}),
|
||||
(200, {"entries": [entry("UPDATE"), entry("DELETE")]}),
|
||||
]
|
||||
)
|
||||
|
||||
async def exercise():
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint, api_key="caller-secret")
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint, api_key="caller-secret")
|
||||
)
|
||||
iterator = catalog.authz.list_acls(subject=Subject.role("reader"), limit=limit)
|
||||
assert requests == []
|
||||
return [entry async for entry in iterator] if asynchronous else list(iterator)
|
||||
|
||||
values = asyncio.run(exercise())
|
||||
expected = ["SELECT", "INSERT", "UPDATE", "DELETE"]
|
||||
assert [entry.privilege for entry in values] == (
|
||||
expected if limit is None else expected[:limit]
|
||||
)
|
||||
assert len(requests) == (0 if limit == 0 else 1 if limit == 1 else 3)
|
||||
for _, headers, body in requests:
|
||||
assert headers["x-api-key"] == "caller-secret"
|
||||
assert body["subject"] == "r:reader"
|
||||
if len(requests) == 3:
|
||||
assert requests[1][2]["continuation_token"] == "opaque/first"
|
||||
assert requests[2][2]["continuation_token"] == "opaque/second"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 501, 503])
|
||||
def test_http_errors_preserve_status_without_replaying_writes(catalog_server, status):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.append((status, {"error": "authorization failure"}))
|
||||
authz = lancedb.connect_catalog(endpoint).authz
|
||||
with pytest.raises(HttpError) as error:
|
||||
authz.add_acl(
|
||||
object="system", subject=Subject.role("operator"), privilege="OPERATE"
|
||||
)
|
||||
assert error.value.status_code == status
|
||||
assert error.value.request_id
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
def test_role_pagination_and_late_failure(catalog_server, asynchronous):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.extend(
|
||||
[
|
||||
(
|
||||
200,
|
||||
{
|
||||
"bindings": [{"subject_id": "g:team", "role": "reader"}],
|
||||
"continuation_token": "next",
|
||||
},
|
||||
),
|
||||
(403, {"error": "denied"}),
|
||||
]
|
||||
)
|
||||
|
||||
async def exercise():
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
iterator = catalog.authz.list_role_bindings(role="reader")
|
||||
first = await anext(iterator) if asynchronous else next(iterator)
|
||||
assert first.subject == Subject.group_id("team")
|
||||
assert len(requests) == 1
|
||||
with pytest.raises(HttpError) as error:
|
||||
if asynchronous:
|
||||
await anext(iterator)
|
||||
else:
|
||||
next(iterator)
|
||||
assert error.value.status_code == 403
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert requests[1][2] == {
|
||||
"role": "reader",
|
||||
"max_results": 100,
|
||||
"continuation_token": "next",
|
||||
}
|
||||
|
||||
|
||||
def test_early_close_does_not_fetch_next_page(catalog_server):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.append(
|
||||
(
|
||||
200,
|
||||
{
|
||||
"bindings": [{"subject_id": "g:team", "role": "reader"}],
|
||||
"continuation_token": "next",
|
||||
},
|
||||
)
|
||||
)
|
||||
iterator = lancedb.connect_catalog(endpoint).authz.list_role_bindings()
|
||||
next(iterator)
|
||||
iterator.close()
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
def test_selectors_objects_and_redaction():
|
||||
secret = Subject.principal_api_key("do-not-print-this")
|
||||
assert "do-not-print-this" not in repr(secret)
|
||||
assert "do-not-print-this" not in str(secret)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
secret.kind = "role"
|
||||
assert str(Object.system()) == "system"
|
||||
assert str(Object.database("tenant/db")) == "database:tenant/db"
|
||||
assert (
|
||||
str(Object.namespace(database="db", namespace_path=["a", "b"]))
|
||||
== "namespace:db:a$b"
|
||||
)
|
||||
assert str(Object.table(database="db", name="events")) == "table:db:public$events"
|
||||
assert str(Object.view(database="db", name="recent")) == "view:db:public$recent"
|
||||
assert Subject.principal_id("p:id")._wire() == "p:p:id"
|
||||
assert Subject.group_id("team-id").value == "team-id"
|
||||
for namespace in (["a$b"], [""], "public", [".."]):
|
||||
with pytest.raises(ValueError):
|
||||
Object.table(database="db", namespace_path=namespace, name="events")
|
||||
for database in ("", "a//b", "a/../b", "a:b", "a%2Fb", "a$b"):
|
||||
with pytest.raises(ValueError):
|
||||
Object.database(database)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_before_io_and_empty_lists():
|
||||
inner = AsyncMock()
|
||||
inner.list_acls.return_value = json.dumps({"entries": []})
|
||||
inner.list_role_bindings.return_value = json.dumps({"bindings": []})
|
||||
authz = AsyncAuthorization(inner)
|
||||
for limit in (-1, 1.5, True):
|
||||
with pytest.raises(ValueError):
|
||||
authz.list_acls(limit=limit)
|
||||
with pytest.raises(ValueError):
|
||||
Authorization(authz).list_role_bindings(limit=limit)
|
||||
with pytest.raises(ValueError):
|
||||
await authz.get_group(Subject.principal_id("alice"))
|
||||
with pytest.raises(ValueError):
|
||||
await authz.get_principal(Subject.group_id("team"))
|
||||
for method in (authz.add_role_binding, authz.delete_role_binding):
|
||||
with pytest.raises(ValueError):
|
||||
await method(subject=Subject.role("reader"), role="writer")
|
||||
with pytest.raises(ValueError):
|
||||
authz.list_role_bindings(subject=Subject.role("reader"))
|
||||
assert [entry async for entry in authz.list_acls(limit=0)] == []
|
||||
assert inner.mock_calls == []
|
||||
assert [entry async for entry in authz.list_acls()] == []
|
||||
assert [entry async for entry in authz.list_role_bindings()] == []
|
||||
|
||||
|
||||
def test_api_key_subject_is_not_logged(catalog_server):
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.append((200, {"principal_id": "public-id", "groups": [], "roles": []}))
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import lancedb, sys; from lancedb.authz import Subject; "
|
||||
"lancedb.connect_catalog(sys.argv[1]).authz.get_principal("
|
||||
"Subject.principal_api_key('test-subject-secret-never-log'))",
|
||||
endpoint,
|
||||
],
|
||||
env={**os.environ, "LANCEDB_LOG": "debug"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert requests[0][2] == {"principal": "a:test-subject-secret-never-log"}
|
||||
assert "test-subject-secret-never-log" not in result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"builder, kwargs, wire",
|
||||
[
|
||||
("system", {}, "system"),
|
||||
("secret", {"database": "db", "name": "api-key"}, "secret:db:public$api-key"),
|
||||
(
|
||||
"function",
|
||||
{"database": "db", "name": "caption"},
|
||||
"function:db:public$caption",
|
||||
),
|
||||
(
|
||||
"secret",
|
||||
{"database": "tenant/db", "namespace_path": ["a", "b"], "name": "api-key"},
|
||||
"secret:tenant/db:a$b$api-key",
|
||||
),
|
||||
(
|
||||
"function",
|
||||
{"database": "tenant/db", "namespace_path": ["a", "b"], "name": "caption"},
|
||||
"function:tenant/db:a$b$caption",
|
||||
),
|
||||
("database", {"name": "tenant/db"}, "database:tenant/db"),
|
||||
(
|
||||
"namespace",
|
||||
{"database": "tenant/db", "namespace_path": ["a", "b"]},
|
||||
"namespace:tenant/db:a$b",
|
||||
),
|
||||
(
|
||||
"table",
|
||||
{"database": "tenant/db", "namespace_path": ["a", "b"], "name": "events"},
|
||||
"table:tenant/db:a$b$events",
|
||||
),
|
||||
(
|
||||
"view",
|
||||
{"database": "db", "namespace_path": ["public"], "name": "recent"},
|
||||
"view:db:public$recent",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rust_backed_object_builders(builder, kwargs, wire):
|
||||
import pickle
|
||||
from lancedb import _lancedb
|
||||
|
||||
value = getattr(Object, builder)(**kwargs)
|
||||
assert str(value) == wire
|
||||
assert str(value._inner) == wire
|
||||
assert str(_lancedb.AuthzObject(wire)) == wire
|
||||
assert value == Object(wire)
|
||||
assert hash(value) == hash(Object(wire))
|
||||
assert pickle.loads(pickle.dumps(value)) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind, prefix",
|
||||
[
|
||||
("principal_id", "p:"),
|
||||
("principal_name", "P:"),
|
||||
("group_id", "g:"),
|
||||
("group_name", "G:"),
|
||||
("role", "r:"),
|
||||
("principal_api_key", "a:"),
|
||||
],
|
||||
)
|
||||
def test_rust_backed_subject_selectors(kind, prefix):
|
||||
import pickle
|
||||
from lancedb import _lancedb
|
||||
|
||||
value = "p:literal@example.com / 名前"
|
||||
subject = getattr(Subject, kind)(value)
|
||||
assert subject.kind == kind
|
||||
assert subject.value == value
|
||||
assert subject._wire() == prefix + value
|
||||
assert _lancedb.AuthzSubject(kind, value).to_wire() == prefix + value
|
||||
assert pickle.loads(pickle.dumps(subject)) == subject
|
||||
assert hash(subject) == hash(getattr(Subject, kind)(value))
|
||||
with pytest.raises(ValueError):
|
||||
getattr(Subject, kind)("")
|
||||
if kind == "principal_api_key":
|
||||
assert value not in repr(subject._inner)
|
||||
assert value not in str(subject._inner)
|
||||
if kind in ("principal_id", "group_id", "role"):
|
||||
assert Subject._canonical(prefix + value) == subject
|
||||
else:
|
||||
with pytest.raises(ValueError) as error:
|
||||
Subject._canonical(prefix + value)
|
||||
assert value not in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wire",
|
||||
[
|
||||
"",
|
||||
"database:",
|
||||
"database:a//b",
|
||||
"database:a/../b",
|
||||
"database:a%2Fb",
|
||||
"namespace:db:a$$b",
|
||||
"table:db:events",
|
||||
"table:db:public$",
|
||||
"table:db:public$..",
|
||||
"table:db:a/b$events",
|
||||
"view:db:public$café",
|
||||
"secret:db:public$",
|
||||
"function:db:public$..",
|
||||
"secret:db:a$$key",
|
||||
"function:db:caption",
|
||||
"secret:db:public$bad/name",
|
||||
"function:db:public$bad:name",
|
||||
"system:db",
|
||||
],
|
||||
)
|
||||
def test_invalid_raw_objects_fail_before_io(catalog_server, wire):
|
||||
endpoint, requests, _ = catalog_server
|
||||
authz = lancedb.connect_catalog(endpoint).authz
|
||||
with pytest.raises(ValueError):
|
||||
Object(wire)
|
||||
with pytest.raises(ValueError):
|
||||
authz.list_acls(object=wire)
|
||||
with pytest.raises(ValueError):
|
||||
authz.add_acl(object=wire, subject=Subject.role("reader"), privilege="SELECT")
|
||||
assert requests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wire",
|
||||
[
|
||||
"future:db:resource",
|
||||
"table_extension:db:resource",
|
||||
"secret:tenant/db:a$b$key",
|
||||
"function:tenant/db:a$b$caption",
|
||||
],
|
||||
)
|
||||
def test_objects_round_trip_through_acl_requests(catalog_server, wire):
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.extend(
|
||||
[
|
||||
(200, {}),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"object": wire,
|
||||
"subject_id": "r:reader",
|
||||
"privilege": "SELECT",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(200, {}),
|
||||
]
|
||||
)
|
||||
authz = lancedb.connect_catalog(endpoint).authz
|
||||
obj = Object(wire)
|
||||
assert str(obj) == wire
|
||||
authz.add_acl(object=obj, subject=Subject.role("reader"), privilege="SELECT")
|
||||
entries = list(authz.list_acls(object=wire))
|
||||
assert entries[0].object == wire
|
||||
authz.delete_acl(
|
||||
object=entries[0].object, subject=Subject.role("reader"), privilege="SELECT"
|
||||
)
|
||||
assert len(requests) == 3
|
||||
for _, _, body in requests:
|
||||
assert body["object"] == wire
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_requests_validate_selector_context_before_io(catalog_server):
|
||||
endpoint, requests, _ = catalog_server
|
||||
catalog = await lancedb.connect_catalog_async(endpoint)
|
||||
native = catalog.authz._inner
|
||||
for method, request in [
|
||||
(native.list_role_bindings, {"subject": "r:reader"}),
|
||||
(native.add_role_binding, {"subject": "r:reader", "role": "writer"}),
|
||||
(native.delete_role_binding, {"subject": "r:reader", "role": "writer"}),
|
||||
(native.get_principal, {"principal": "g:group"}),
|
||||
(native.get_group, {"group": "a:never-log-this-key"}),
|
||||
]:
|
||||
with pytest.raises(ValueError) as error:
|
||||
await method(json.dumps(request))
|
||||
assert "never-log-this-key" not in str(error.value)
|
||||
assert requests == []
|
||||
|
||||
|
||||
def test_privilege_values_use_rust_serialization():
|
||||
import pickle
|
||||
from lancedb import _lancedb
|
||||
|
||||
names = [value.value for value in Privilege]
|
||||
assert names == _lancedb.AuthzPrivilege.known()
|
||||
for privilege in Privilege:
|
||||
assert json.loads(json.dumps(privilege)) == privilege.value
|
||||
assert str(_lancedb.AuthzPrivilege(privilege)) == privilege.value
|
||||
assert pickle.loads(pickle.dumps(privilege)) is privilege
|
||||
for index in range(64):
|
||||
alias = f"PRIVILEGE{index}"
|
||||
expected = names[index] if index < len(names) else alias
|
||||
assert str(_lancedb.AuthzPrivilege(alias)) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"creation_privilege", [None, "CREATE_SECRET", "CREATE_FUNCTION"]
|
||||
)
|
||||
def test_privilege_names_and_future_values_round_trip(
|
||||
catalog_server, asynchronous, creation_privilege
|
||||
):
|
||||
endpoint, requests, responses = catalog_server
|
||||
inputs = [
|
||||
Privilege.SELECT,
|
||||
"SELECT",
|
||||
"PRIVILEGE2",
|
||||
"PRIVILEGE20",
|
||||
"PRIVILEGE63",
|
||||
"PRIVILEGE64",
|
||||
"PRIVILEGE256",
|
||||
"PRIVILEGE18446744073709551616",
|
||||
]
|
||||
expected = ["SELECT", "SELECT", "SELECT", *inputs[3:]]
|
||||
object_id = "table:db:public$t"
|
||||
if creation_privilege is not None:
|
||||
inputs = [Privilege[creation_privilege], creation_privilege]
|
||||
expected = [creation_privilege, creation_privilege]
|
||||
object_id = "namespace:db:public"
|
||||
for privilege in inputs:
|
||||
responses.extend(
|
||||
[
|
||||
(200, {}),
|
||||
(
|
||||
200,
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"object": object_id,
|
||||
"subject_id": "r:reader",
|
||||
"privilege": privilege,
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(200, {}),
|
||||
]
|
||||
)
|
||||
|
||||
async def exercise():
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
authz = catalog.authz
|
||||
for privilege, canonical in zip(inputs, expected):
|
||||
added = authz.add_acl(
|
||||
object=object_id,
|
||||
subject=Subject.role("reader"),
|
||||
privilege=privilege,
|
||||
)
|
||||
if asynchronous:
|
||||
await added
|
||||
iterator = authz.list_acls()
|
||||
entries = (
|
||||
[entry async for entry in iterator] if asynchronous else list(iterator)
|
||||
)
|
||||
assert entries[0].privilege == canonical
|
||||
deleted = authz.delete_acl(
|
||||
object=entries[0].object,
|
||||
subject=entries[0].subject,
|
||||
privilege=entries[0].privilege,
|
||||
)
|
||||
if asynchronous:
|
||||
await deleted
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert len(requests) == 3 * len(inputs)
|
||||
for index, canonical in enumerate(expected):
|
||||
assert requests[index * 3][2]["object"] == object_id
|
||||
assert requests[index * 3][2]["privilege"] == canonical
|
||||
assert requests[index * 3 + 2][2]["object"] == object_id
|
||||
assert requests[index * 3 + 2][2]["privilege"] == canonical
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
def test_invalid_privileges_fail_before_io(catalog_server, asynchronous):
|
||||
endpoint, requests, _ = catalog_server
|
||||
invalid = [
|
||||
"",
|
||||
"select",
|
||||
"SELECT ",
|
||||
"UNKNOWN",
|
||||
"PRIVILEGE",
|
||||
"PRIVILEGE-1",
|
||||
"PRIVILEGE+1",
|
||||
"PRIVILEGE1x",
|
||||
None,
|
||||
2,
|
||||
True,
|
||||
["SELECT"],
|
||||
]
|
||||
|
||||
async def exercise():
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
async_authz = catalog.authz if asynchronous else catalog.authz._inner
|
||||
native = async_authz._inner
|
||||
for privilege in invalid:
|
||||
for method in [catalog.authz.add_acl, catalog.authz.delete_acl]:
|
||||
with pytest.raises(ValueError, match="privilege"):
|
||||
result = method(
|
||||
object="system",
|
||||
subject=Subject.role("reader"),
|
||||
privilege=privilege,
|
||||
)
|
||||
if asynchronous:
|
||||
await result
|
||||
for method in [
|
||||
native.add_acl,
|
||||
native.delete_acl,
|
||||
]:
|
||||
with pytest.raises(ValueError):
|
||||
await method(
|
||||
json.dumps(
|
||||
{
|
||||
"object": "system",
|
||||
"subject": "r:reader",
|
||||
"privilege": privilege,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert requests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
def test_acl_page_preserves_unknown_subjects(catalog_server, asynchronous):
|
||||
import pickle
|
||||
|
||||
endpoint, requests, responses = catalog_server
|
||||
responses.extend(
|
||||
[
|
||||
(
|
||||
200,
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"subject_id": "p:known",
|
||||
"object": "system",
|
||||
"privilege": "USAGE",
|
||||
},
|
||||
{
|
||||
"subject_id": "x:future-subject",
|
||||
"object": "system",
|
||||
"privilege": "USAGE",
|
||||
},
|
||||
]
|
||||
},
|
||||
),
|
||||
(200, {}),
|
||||
]
|
||||
)
|
||||
|
||||
async def exercise():
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
iterator = catalog.authz.list_acls()
|
||||
entries = (
|
||||
[entry async for entry in iterator] if asynchronous else list(iterator)
|
||||
)
|
||||
assert len(entries) == 2
|
||||
assert entries[0].subject == Subject.principal_id("known")
|
||||
subject = entries[1].subject
|
||||
assert subject.kind == "unknown"
|
||||
assert subject.value == "x:future-subject"
|
||||
assert subject._wire() == "x:future-subject"
|
||||
assert pickle.loads(pickle.dumps(subject)) == subject
|
||||
result = catalog.authz.delete_acl(
|
||||
object=entries[1].object, subject=subject, privilege=entries[1].privilege
|
||||
)
|
||||
if asynchronous:
|
||||
await result
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert len(requests) == 2
|
||||
assert requests[1][2]["subject"] == "x:future-subject"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("builder", ["secret", "function"])
|
||||
def test_secret_and_function_builders_validate_components(builder):
|
||||
from lancedb import _lancedb
|
||||
|
||||
for database, namespace, name in [
|
||||
("a//b", ["public"], "valid"),
|
||||
("db", ["a$b"], "valid"),
|
||||
("db", "public", "valid"),
|
||||
("db", ["public"], "bad$name"),
|
||||
("db", ["public"], ".."),
|
||||
]:
|
||||
with pytest.raises(ValueError):
|
||||
getattr(Object, builder)(
|
||||
database=database, namespace_path=namespace, name=name
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
getattr(_lancedb.AuthzObject, builder)(database, namespace, name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"builder", ["namespace", "table", "view", "secret", "function"]
|
||||
)
|
||||
def test_empty_and_omitted_namespace_paths_select_public(builder):
|
||||
from lancedb import _lancedb
|
||||
|
||||
kwargs = {"database": "db"}
|
||||
native_args = ()
|
||||
wire = "namespace:db:public"
|
||||
if builder != "namespace":
|
||||
kwargs["name"] = "item"
|
||||
native_args = ("item",)
|
||||
wire = f"{builder}:db:public$item"
|
||||
|
||||
build = getattr(Object, builder)
|
||||
expected = build(**kwargs, namespace_path=["public"])
|
||||
assert str(expected) == wire
|
||||
assert build(**kwargs) == expected
|
||||
for namespace in [[], ()]:
|
||||
assert build(**kwargs, namespace_path=namespace) == expected
|
||||
assert (
|
||||
str(getattr(_lancedb.AuthzObject, builder)("db", namespace, *native_args))
|
||||
== wire
|
||||
)
|
||||
@@ -263,3 +263,45 @@ async def test_database_names_invalid_limit(catalog_server, asynchronous, page_l
|
||||
next(names)
|
||||
assert names.num_page_results() == 0
|
||||
assert requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
async def test_catalog_rejects_invalid_database_names_before_io(
|
||||
catalog_server, asynchronous
|
||||
):
|
||||
endpoint, requests, _ = catalog_server
|
||||
catalog = (
|
||||
await lancedb.connect_catalog_async(endpoint)
|
||||
if asynchronous
|
||||
else lancedb.connect_catalog(endpoint)
|
||||
)
|
||||
for name in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"/db",
|
||||
"db/",
|
||||
"a//b",
|
||||
"a/./b",
|
||||
"a/../b",
|
||||
"a b",
|
||||
"a:b",
|
||||
"a$b",
|
||||
"a%b",
|
||||
"a?b",
|
||||
"a#b",
|
||||
"a\\b",
|
||||
"café",
|
||||
"a\nb",
|
||||
]:
|
||||
for method in [
|
||||
catalog.create_database,
|
||||
catalog.connect_database,
|
||||
catalog.drop_database,
|
||||
]:
|
||||
with pytest.raises(ValueError, match="Invalid database name"):
|
||||
result = method(name)
|
||||
if asynchronous:
|
||||
await result
|
||||
assert requests == []
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use crate::error::PythonErrorExt;
|
||||
use crate::runtime::future_into_py;
|
||||
use lancedb::authz::*;
|
||||
use pyo3::exceptions::{PyRuntimeError, PyValueError};
|
||||
use pyo3::types::{PyAnyMethods, PyBytes, PyString};
|
||||
use pyo3::{Bound, PyAny, PyRef, PyResult, pyclass, pymethods};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[pyclass]
|
||||
pub struct AuthorizationClient {
|
||||
pub(crate) inner: Arc<dyn Authorization>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl AuthorizationClient {
|
||||
fn list_acls<'py>(self_: PyRef<'py, Self>, request: &str) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: ListAccessControlEntriesRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let result = inner.list_acls(request).await.infer_error()?;
|
||||
serde_json::to_string(&result).map_err(|err| PyRuntimeError::new_err(err.to_string()))
|
||||
})
|
||||
}
|
||||
fn add_acl<'py>(self_: PyRef<'py, Self>, request: &str) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: AccessControlEntryRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner.add_acl(request).await.infer_error()
|
||||
})
|
||||
}
|
||||
fn delete_acl<'py>(self_: PyRef<'py, Self>, request: &str) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: AccessControlEntryRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner.delete_acl(request).await.infer_error()
|
||||
})
|
||||
}
|
||||
fn list_role_bindings<'py>(
|
||||
self_: PyRef<'py, Self>,
|
||||
request: &str,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: ListRoleBindingsRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
request.validate().infer_error()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let result = inner.list_role_bindings(request).await.infer_error()?;
|
||||
serde_json::to_string(&result).map_err(|err| PyRuntimeError::new_err(err.to_string()))
|
||||
})
|
||||
}
|
||||
fn add_role_binding<'py>(
|
||||
self_: PyRef<'py, Self>,
|
||||
request: &str,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: RoleBindingRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
request.validate().infer_error()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner.add_role_binding(request).await.infer_error()
|
||||
})
|
||||
}
|
||||
fn delete_role_binding<'py>(
|
||||
self_: PyRef<'py, Self>,
|
||||
request: &str,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: RoleBindingRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
request.validate().infer_error()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
inner.delete_role_binding(request).await.infer_error()
|
||||
})
|
||||
}
|
||||
fn get_principal<'py>(self_: PyRef<'py, Self>, request: &str) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: GetPrincipalRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
request.validate().infer_error()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let result = inner.get_principal(request).await.infer_error()?;
|
||||
serde_json::to_string(&result).map_err(|err| PyRuntimeError::new_err(err.to_string()))
|
||||
})
|
||||
}
|
||||
fn get_group<'py>(self_: PyRef<'py, Self>, request: &str) -> PyResult<Bound<'py, PyAny>> {
|
||||
let request: GetGroupRequest = serde_json::from_str(request)
|
||||
.map_err(|_| PyValueError::new_err("Invalid authorization request"))?;
|
||||
request.validate().infer_error()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let result = inner.get_group(request).await.infer_error()?;
|
||||
serde_json::to_string(&result).map_err(|err| PyRuntimeError::new_err(err.to_string()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared privilege parser and canonical wire names.
|
||||
#[pyclass(frozen)]
|
||||
pub struct AuthzPrivilege {
|
||||
inner: Privilege,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl AuthzPrivilege {
|
||||
#[new]
|
||||
fn new(value: &str) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: value.parse().map_err(PyValueError::new_err)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn known() -> Vec<String> {
|
||||
Privilege::KNOWN.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
self.inner.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass(frozen)]
|
||||
pub struct AuthzSubject {
|
||||
inner: Subject,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl AuthzSubject {
|
||||
#[new]
|
||||
fn new(kind: &str, value: &Bound<'_, PyAny>) -> PyResult<Self> {
|
||||
let value: String = value
|
||||
.extract()
|
||||
.map_err(|_| PyValueError::new_err("Subject value must be a nonempty string"))?;
|
||||
Ok(Self {
|
||||
inner: Subject::new(kind.parse().infer_error()?, value).infer_error()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn from_canonical(value: &str) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: Subject::from_canonical(value).infer_error()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn kind(&self) -> &str {
|
||||
self.inner.kind().as_str()
|
||||
}
|
||||
#[getter]
|
||||
fn value(&self) -> &str {
|
||||
self.inner.value()
|
||||
}
|
||||
#[getter]
|
||||
fn display_value(&self) -> &str {
|
||||
self.inner.display_value()
|
||||
}
|
||||
|
||||
#[pyo3(signature = (context=None))]
|
||||
fn to_wire(&self, context: Option<&str>) -> PyResult<String> {
|
||||
match context {
|
||||
None => {}
|
||||
Some("principal") => self.inner.validate_principal().infer_error()?,
|
||||
Some("group") => self.inner.validate_group().infer_error()?,
|
||||
Some("role_member") => self.inner.validate_role_member().infer_error()?,
|
||||
Some(_) => return Err(PyValueError::new_err("Invalid subject context")),
|
||||
}
|
||||
Ok(self.inner.to_wire())
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{:?}", self.inner)
|
||||
}
|
||||
fn __str__(&self) -> String {
|
||||
self.inner.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass(frozen)]
|
||||
pub struct AuthzObject {
|
||||
inner: Object,
|
||||
}
|
||||
|
||||
impl AuthzObject {
|
||||
fn validated(inner: Object) -> PyResult<Self> {
|
||||
inner.validate().map_err(PyValueError::new_err)?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
}
|
||||
|
||||
fn namespace_path(value: &Bound<'_, PyAny>) -> PyResult<NamespacePath> {
|
||||
if value.is_instance_of::<PyString>() || value.is_instance_of::<PyBytes>() {
|
||||
return Err(PyValueError::new_err(
|
||||
"namespace must be a sequence of components",
|
||||
));
|
||||
}
|
||||
let components = value
|
||||
.extract()
|
||||
.map_err(|_| PyValueError::new_err("namespace must be a sequence of string components"))?;
|
||||
NamespacePath::new(components).map_err(PyValueError::new_err)
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl AuthzObject {
|
||||
#[new]
|
||||
fn new(value: &str) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: value.parse().infer_error()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn system() -> Self {
|
||||
Self {
|
||||
inner: Object::System,
|
||||
}
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn database(name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::Database(DatabaseObject::new(name.into())))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn namespace(database: &str, namespace: &Bound<'_, PyAny>) -> PyResult<Self> {
|
||||
Self::validated(Object::Namespace(NamespaceObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
}))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn table(database: &str, namespace: &Bound<'_, PyAny>, name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::Table(TableObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
table: name.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn view(database: &str, namespace: &Bound<'_, PyAny>, name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::View(ViewObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
view: name.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn secret(database: &str, namespace: &Bound<'_, PyAny>, name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::Secret(SecretObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
secret: name.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn function(database: &str, namespace: &Bound<'_, PyAny>, name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::Function(FunctionObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
function: name.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
self.inner.to_string()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{:?}", self.inner)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,13 @@ pub struct Catalog {
|
||||
|
||||
#[pymethods]
|
||||
impl Catalog {
|
||||
#[getter]
|
||||
fn authz(&self) -> PyResult<crate::authz::AuthorizationClient> {
|
||||
Ok(crate::authz::AuthorizationClient {
|
||||
inner: self.inner.authz().infer_error()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn uri(&self) -> &str {
|
||||
self.inner.uri()
|
||||
|
||||
@@ -22,6 +22,7 @@ use table::{
|
||||
};
|
||||
|
||||
pub mod arrow;
|
||||
pub mod authz;
|
||||
pub mod catalog;
|
||||
pub mod connection;
|
||||
pub mod error;
|
||||
@@ -70,6 +71,10 @@ pub fn _lancedb(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<listing::FunctionListing>()?;
|
||||
m.add_class::<listing::JobListing>()?;
|
||||
|
||||
m.add_class::<authz::AuthorizationClient>()?;
|
||||
m.add_class::<authz::AuthzSubject>()?;
|
||||
m.add_class::<authz::AuthzObject>()?;
|
||||
m.add_class::<authz::AuthzPrivilege>()?;
|
||||
m.add_function(wrap_pyfunction!(catalog::connect_catalog, m)?)?;
|
||||
m.add_class::<Session>()?;
|
||||
m.add_class::<Table>()?;
|
||||
|
||||
@@ -66,6 +66,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
async-openai = { version = "0.20.0", optional = true }
|
||||
serde_with = { version = "3.8.1" }
|
||||
strum_macros = "0.28"
|
||||
tempfile = { workspace = true }
|
||||
aws-smithy-types = { workspace = true, optional = true }
|
||||
# Default features include the legacy `rustls` feature, which pulls in rustls 0.21.
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Authorization requests, responses, and the deployment-wide API.
|
||||
|
||||
use super::{Object, Privilege, Subject};
|
||||
use crate::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Filters and pagination for a page of ACL grants. Omitted filters match all.
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct ListAccessControlEntriesRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub object: Option<Object>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<Subject>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub continuation_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Maximum stored subject/object pairs, not expanded privilege entries.
|
||||
/// The server caps this at 100; None uses its default. Must be positive.
|
||||
pub max_results: Option<u32>,
|
||||
}
|
||||
|
||||
/// One privilege granted to a canonical subject on an object.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct AccessControlEntry {
|
||||
#[serde(deserialize_with = "Subject::deserialize_canonical")]
|
||||
pub subject_id: Subject,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject_name: Option<String>,
|
||||
pub object: Object,
|
||||
pub privilege: Privilege,
|
||||
}
|
||||
|
||||
/// One page of grants. A stored subject/object pair may expand to several entries.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct ListAccessControlEntriesResponse {
|
||||
pub entries: Vec<AccessControlEntry>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub continuation_token: Option<String>,
|
||||
}
|
||||
|
||||
/// One grant to add or delete. The subject may be selected by ID, name, or API key.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct AccessControlEntryRequest {
|
||||
pub subject: Subject,
|
||||
pub object: Object,
|
||||
/// A privilege such as [`Privilege::Select`] or [`Privilege::Usage`].
|
||||
/// [`Privilege::Unknown`] preserves future privilege names.
|
||||
pub privilege: Privilege,
|
||||
}
|
||||
|
||||
/// Filters and pagination for direct principal/group role bindings.
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct ListRoleBindingsRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<Subject>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub continuation_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Maximum bindings on this page; the server caps this at 100.
|
||||
pub max_results: Option<u32>,
|
||||
}
|
||||
|
||||
/// A direct principal or group membership in a LanceDB-managed role.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct RoleBinding {
|
||||
#[serde(deserialize_with = "Subject::deserialize_canonical")]
|
||||
pub subject_id: Subject,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject_name: Option<String>,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// One page of direct bindings, without expanding group membership.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct ListRoleBindingsResponse {
|
||||
pub bindings: Vec<RoleBinding>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub continuation_token: Option<String>,
|
||||
}
|
||||
|
||||
/// A role to grant to or revoke from a principal or group. Roles cannot nest.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct RoleBindingRequest {
|
||||
pub subject: Subject,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// Select a principal by canonical ID, provider name, or API key.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct GetPrincipalRequest {
|
||||
pub principal: Subject,
|
||||
}
|
||||
|
||||
/// Select a group by canonical ID or provider name.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct GetGroupRequest {
|
||||
pub group: Subject,
|
||||
}
|
||||
|
||||
/// A canonical identity-provider ID and optional display name.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct IdentityInfo {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// A principal, its provider-managed groups, and roles held directly or via groups.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct PrincipalInfo {
|
||||
pub principal_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub principal_name: Option<String>,
|
||||
pub groups: Vec<IdentityInfo>,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
/// A group, its provider-managed members, and its directly granted roles.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct GroupInfo {
|
||||
pub group_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group_name: Option<String>,
|
||||
pub principals: Vec<IdentityInfo>,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
/// Authorization operations on one deployment, independent of database selection.
|
||||
///
|
||||
/// ACL writes preserve server conflict/not-found errors and must not be replayed
|
||||
/// after ambiguous transport failures. Role-binding writes are idempotent.
|
||||
#[async_trait::async_trait]
|
||||
pub trait Authorization: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// List a page of ACLs. Requires USAGE on the object, or system if unfiltered.
|
||||
async fn list_acls(
|
||||
&self,
|
||||
request: ListAccessControlEntriesRequest,
|
||||
) -> Result<ListAccessControlEntriesResponse>;
|
||||
/// Grant a privilege; requires OWNERSHIP. Duplicate grants return HTTP 409.
|
||||
/// Granting OWNERSHIP transfers it from the previous owner.
|
||||
async fn add_acl(&self, request: AccessControlEntryRequest) -> Result<()>;
|
||||
/// Revoke a privilege; requires OWNERSHIP. Missing grants return HTTP 404.
|
||||
/// OWNERSHIP cannot be deleted.
|
||||
async fn delete_acl(&self, request: AccessControlEntryRequest) -> Result<()>;
|
||||
/// List a page of direct bindings; requires USAGE on system.
|
||||
async fn list_role_bindings(
|
||||
&self,
|
||||
request: ListRoleBindingsRequest,
|
||||
) -> Result<ListRoleBindingsResponse>;
|
||||
/// Idempotently grant a role; requires OPERATE on system.
|
||||
async fn add_role_binding(&self, request: RoleBindingRequest) -> Result<()>;
|
||||
/// Idempotently revoke a role; requires OPERATE on system.
|
||||
async fn delete_role_binding(&self, request: RoleBindingRequest) -> Result<()>;
|
||||
/// Inspect a principal and effective roles; requires USAGE on system.
|
||||
async fn get_principal(&self, request: GetPrincipalRequest) -> Result<PrincipalInfo>;
|
||||
/// Inspect a group and direct roles; requires USAGE on system.
|
||||
async fn get_group(&self, request: GetGroupRequest) -> Result<GroupInfo>;
|
||||
}
|
||||
|
||||
impl AccessControlEntryRequest {
|
||||
/// Build one grant from validated resource and identity selectors.
|
||||
pub fn new(object: Object, subject: Subject, privilege: Privilege) -> Self {
|
||||
Self {
|
||||
object,
|
||||
subject,
|
||||
privilege,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ListRoleBindingsRequest {
|
||||
/// Reject role subjects, since roles cannot contain other roles.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Some(subject) = &self.subject {
|
||||
subject.validate_role_member()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RoleBindingRequest {
|
||||
/// Build a role grant/revocation for a principal or group.
|
||||
pub fn new(subject: Subject, role: impl Into<String>) -> Result<Self> {
|
||||
subject.validate_role_member()?;
|
||||
Ok(Self {
|
||||
subject,
|
||||
role: role.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate the member selector, including deserialized requests.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.subject.validate_role_member()
|
||||
}
|
||||
}
|
||||
|
||||
impl GetPrincipalRequest {
|
||||
/// Select a principal by ID, name, or API key.
|
||||
pub fn new(principal: Subject) -> Result<Self> {
|
||||
principal.validate_principal()?;
|
||||
Ok(Self { principal })
|
||||
}
|
||||
|
||||
/// Validate the lookup selector, including deserialized requests.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.principal.validate_principal()
|
||||
}
|
||||
}
|
||||
|
||||
impl GetGroupRequest {
|
||||
/// Select a group by ID or name.
|
||||
pub fn new(group: Subject) -> Result<Self> {
|
||||
group.validate_group()?;
|
||||
Ok(Self { group })
|
||||
}
|
||||
|
||||
/// Validate the lookup selector, including deserialized requests.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.group.validate_group()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Deployment-wide authorization, accessed through a catalog.
|
||||
//!
|
||||
//! Subjects use `p:ID`, `P:NAME`, `g:ID`, `G:NAME`, `r:ROLE`, or `a:API_KEY`.
|
||||
//! Objects use literal names such as `table:tenant/db:public$events`.
|
||||
//! Names are resolved by the server. Request types intentionally do not implement
|
||||
//! Debug because their subject selectors may contain secret API keys.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(feature = "remote")]
|
||||
//! # async fn example() -> lancedb::Result<()> {
|
||||
//! let catalog = lancedb::connect_catalog("https://example.com")
|
||||
//! .api_key("secret")
|
||||
//! .execute().await?;
|
||||
//! let authz = catalog.authz()?;
|
||||
//! use lancedb::authz::{AccessControlEntryRequest, Object, TableObject, NamespacePath, Subject, Privilege};
|
||||
//! let grant = AccessControlEntryRequest::new(
|
||||
//! Object::Table(TableObject {
|
||||
//! database: "analytics".into(),
|
||||
//! namespace: NamespacePath::from_component("public"),
|
||||
//! table: "foo".into(),
|
||||
//! }),
|
||||
//! Subject::role("reader")?,
|
||||
//! Privilege::Select,
|
||||
//! );
|
||||
//! authz.add_acl(grant).await?;
|
||||
//! let page = authz.list_acls(Default::default()).await?;
|
||||
//! for entry in page.entries {
|
||||
//! println!("{} {} {}", entry.object, entry.subject_id, entry.privilege);
|
||||
//! }
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod api;
|
||||
pub mod object;
|
||||
pub mod privilege;
|
||||
pub mod subject;
|
||||
|
||||
pub use api::*;
|
||||
pub use object::{
|
||||
DatabaseObject, FunctionObject, NamespaceObject, NamespacePath, Object, SecretObject,
|
||||
TableObject, ViewObject,
|
||||
};
|
||||
pub use privilege::*;
|
||||
pub use subject::{Subject, SubjectKind};
|
||||
@@ -0,0 +1,992 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use crate::utils::{
|
||||
validate_database_name, validate_namespace_component, validate_secret_name,
|
||||
validate_table_name, validate_view_name,
|
||||
};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _, ser::Error as _};
|
||||
|
||||
/// The default namespace for authorization resources.
|
||||
pub const DEFAULT_NAMESPACE: &str = "public";
|
||||
|
||||
pub const SYSTEM_TYPE_STRING: &str = "system";
|
||||
pub const DATABASE_TYPE_STRING: &str = "database";
|
||||
pub const NAMESPACE_TYPE_STRING: &str = "namespace";
|
||||
pub const TABLE_TYPE_STRING: &str = "table";
|
||||
pub const VIEW_TYPE_STRING: &str = "view";
|
||||
pub const SECRET_TYPE_STRING: &str = "secret";
|
||||
pub const FUNCTION_TYPE_STRING: &str = "function";
|
||||
pub const UNKNOWN_TYPE_STRING: &str = "unknown";
|
||||
|
||||
/// A non-empty path through the namespace hierarchy.
|
||||
///
|
||||
/// The `$` separator is only the canonical wire encoding. Authorization code
|
||||
/// should use the components and prefix helpers instead of interpreting the
|
||||
/// encoded form.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NamespacePath(Vec<String>);
|
||||
|
||||
impl NamespacePath {
|
||||
/// Construct a path, validating each component without splitting separators.
|
||||
/// An empty path selects the default `public` namespace.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::NamespacePath;
|
||||
///
|
||||
/// let path = NamespacePath::new(vec![]).unwrap();
|
||||
/// assert_eq!(path.components(), &["public"]);
|
||||
/// assert_eq!(path, NamespacePath::default());
|
||||
/// ```
|
||||
pub fn new(components: Vec<String>) -> Result<Self, String> {
|
||||
if components.is_empty() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
for component in &components {
|
||||
validate_namespace_component(component).map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(Self(components))
|
||||
}
|
||||
|
||||
/// Construct a single-component path.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the component is invalid. Use [`Self::new`] for untrusted input.
|
||||
pub fn from_component(component: impl Into<String>) -> Self {
|
||||
Self::new(vec![component.into()]).expect("namespace components must be valid")
|
||||
}
|
||||
|
||||
pub fn components(&self) -> &[String] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn parent(&self) -> Option<Self> {
|
||||
(self.0.len() > 1).then(|| Self(self.0[..self.0.len() - 1].to_vec()))
|
||||
}
|
||||
|
||||
pub fn prefixes(&self) -> impl Iterator<Item = Self> + '_ {
|
||||
(1..=self.0.len()).map(|length| Self(self.0[..length].to_vec()))
|
||||
}
|
||||
|
||||
pub fn is_descendant_of(&self, ancestor: &Self) -> bool {
|
||||
self.0.len() > ancestor.0.len() && self.0.starts_with(&ancestor.0)
|
||||
}
|
||||
|
||||
pub fn parse(path: &str) -> Result<Self, String> {
|
||||
Self::new(path.split('$').map(str::to_string).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NamespacePath {
|
||||
/// Select the default `public` namespace.
|
||||
fn default() -> Self {
|
||||
Self(vec![DEFAULT_NAMESPACE.to_string()])
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NamespacePath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0.join("$"))
|
||||
}
|
||||
}
|
||||
|
||||
/// An authorization resource, serialized using its canonical wire name.
|
||||
///
|
||||
/// Unknown resource types are preserved so clients can read rules created by
|
||||
/// newer servers. Known resource types validate names during parsing and serialization.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Object {
|
||||
System,
|
||||
Database(DatabaseObject),
|
||||
Namespace(NamespaceObject),
|
||||
Table(TableObject),
|
||||
View(ViewObject),
|
||||
Secret(SecretObject),
|
||||
Function(FunctionObject),
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl Object {
|
||||
/// Validate resource names, including objects recovered from storage keys.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
match self {
|
||||
Self::System => Ok(()),
|
||||
Self::Database(object) => object.validate(),
|
||||
Self::Namespace(object) => object.validate(),
|
||||
Self::Table(object) => object.validate(),
|
||||
Self::View(object) => object.validate(),
|
||||
Self::Secret(object) => object.validate(),
|
||||
Self::Function(object) => object.validate(),
|
||||
Self::Unknown(value) if value.is_empty() => Err("object must not be empty".into()),
|
||||
Self::Unknown(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The database an object belongs to, or None.
|
||||
pub fn database(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::System => None,
|
||||
Self::Database(object) => Some(&object.database),
|
||||
Self::Namespace(object) => Some(&object.database),
|
||||
Self::Table(object) => Some(&object.database),
|
||||
Self::View(object) => Some(&object.database),
|
||||
Self::Secret(object) => Some(&object.database),
|
||||
Self::Function(object) => Some(&object.database),
|
||||
Self::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::System => SYSTEM_TYPE_STRING,
|
||||
Self::Database(_) => DATABASE_TYPE_STRING,
|
||||
Self::Namespace(_) => NAMESPACE_TYPE_STRING,
|
||||
Self::Table(_) => TABLE_TYPE_STRING,
|
||||
Self::View(_) => VIEW_TYPE_STRING,
|
||||
Self::Secret(_) => SECRET_TYPE_STRING,
|
||||
Self::Function(_) => FUNCTION_TYPE_STRING,
|
||||
Self::Unknown(_) => UNKNOWN_TYPE_STRING,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Object {
|
||||
type Err = crate::Error;
|
||||
|
||||
fn from_str(value: &str) -> crate::Result<Self> {
|
||||
Self::deserialize(serde::de::value::StrDeserializer::<serde::de::value::Error>::new(value))
|
||||
.map_err(|error| crate::Error::InvalidInput {
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Object {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::System => f.write_str(SYSTEM_TYPE_STRING),
|
||||
Self::Database(object) => object.fmt(f),
|
||||
Self::Namespace(object) => object.fmt(f),
|
||||
Self::Table(object) => object.fmt(f),
|
||||
Self::View(object) => object.fmt(f),
|
||||
Self::Secret(object) => object.fmt(f),
|
||||
Self::Function(object) => object.fmt(f),
|
||||
Self::Unknown(s) => f.write_str(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Object {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Object {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let kind = s.split(':').next().unwrap_or_default();
|
||||
if s == SYSTEM_TYPE_STRING {
|
||||
Ok(Self::System)
|
||||
} else if s.is_empty() || kind == SYSTEM_TYPE_STRING {
|
||||
Err(D::Error::custom(format!("invalid Object {s:?}")))
|
||||
} else if kind == DATABASE_TYPE_STRING {
|
||||
let database = DatabaseObject::deserialize(serde::de::value::StringDeserializer::<
|
||||
D::Error,
|
||||
>::new(s))?;
|
||||
Ok(Self::Database(database))
|
||||
} else if kind == NAMESPACE_TYPE_STRING {
|
||||
let namespace = NamespaceObject::deserialize(serde::de::value::StringDeserializer::<
|
||||
D::Error,
|
||||
>::new(s))?;
|
||||
Ok(Self::Namespace(namespace))
|
||||
} else if kind == TABLE_TYPE_STRING {
|
||||
let table =
|
||||
TableObject::deserialize(serde::de::value::StringDeserializer::<D::Error>::new(s))?;
|
||||
Ok(Self::Table(table))
|
||||
} else if kind == VIEW_TYPE_STRING {
|
||||
let view =
|
||||
ViewObject::deserialize(serde::de::value::StringDeserializer::<D::Error>::new(s))?;
|
||||
Ok(Self::View(view))
|
||||
} else if kind == SECRET_TYPE_STRING {
|
||||
let secret = SecretObject::deserialize(
|
||||
serde::de::value::StringDeserializer::<D::Error>::new(s),
|
||||
)?;
|
||||
Ok(Self::Secret(secret))
|
||||
} else if kind == FUNCTION_TYPE_STRING {
|
||||
let function = FunctionObject::deserialize(serde::de::value::StringDeserializer::<
|
||||
D::Error,
|
||||
>::new(s))?;
|
||||
Ok(Self::Function(function))
|
||||
} else {
|
||||
Ok(Self::Unknown(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A database resource, encoded as `database:NAME`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct DatabaseObject {
|
||||
pub database: String,
|
||||
}
|
||||
|
||||
impl DatabaseObject {
|
||||
pub fn new(database: String) -> Self {
|
||||
Self { database }
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DatabaseObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "database:{}", self.database)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for DatabaseObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DatabaseObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let database = s
|
||||
.strip_prefix("database:")
|
||||
.ok_or_else(|| D::Error::custom(format!("invalid DatabaseObject {s:?}")))?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// A namespace resource, encoded as `namespace:DATABASE:PATH`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NamespaceObject {
|
||||
pub database: String,
|
||||
pub namespace: NamespacePath,
|
||||
}
|
||||
|
||||
impl NamespaceObject {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NamespaceObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "namespace:{}:{}", self.database, self.namespace)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for NamespaceObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for NamespaceObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let invalid = || D::Error::custom(format!("invalid NamespaceObject {s:?}"));
|
||||
let rest = s.strip_prefix("namespace:").ok_or_else(invalid)?;
|
||||
let (database, namespace) = rest.split_once(':').ok_or_else(invalid)?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
namespace: NamespacePath::parse(namespace).map_err(D::Error::custom)?,
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// A table resource, encoded as `table:DATABASE:NAMESPACE$TABLE`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct TableObject {
|
||||
pub database: String,
|
||||
pub namespace: NamespacePath,
|
||||
pub table: String,
|
||||
}
|
||||
|
||||
impl TableObject {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
validate_table_name(&self.table).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TableObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"table:{}:{}${}",
|
||||
self.database, self.namespace, self.table
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for TableObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TableObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let invalid = || D::Error::custom(format!("invalid TableObject {s:?}"));
|
||||
let rest = s.strip_prefix("table:").ok_or_else(invalid)?;
|
||||
let (database, path) = rest.split_once(':').ok_or_else(invalid)?;
|
||||
let (namespace, table) = path.rsplit_once('$').ok_or_else(invalid)?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
namespace: NamespacePath::parse(namespace).map_err(D::Error::custom)?,
|
||||
table: table.to_string(),
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// A view resource, encoded as `view:DATABASE:NAMESPACE$VIEW`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ViewObject {
|
||||
pub database: String,
|
||||
pub namespace: NamespacePath,
|
||||
pub view: String,
|
||||
}
|
||||
|
||||
impl ViewObject {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
validate_view_name(&self.view).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ViewObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "view:{}:{}${}", self.database, self.namespace, self.view)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ViewObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ViewObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let invalid = || D::Error::custom(format!("invalid ViewObject {s:?}"));
|
||||
let rest = s.strip_prefix("view:").ok_or_else(invalid)?;
|
||||
let (database, path) = rest.split_once(':').ok_or_else(invalid)?;
|
||||
let (namespace, view) = path.rsplit_once('$').ok_or_else(invalid)?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
namespace: NamespacePath::parse(namespace).map_err(D::Error::custom)?,
|
||||
view: view.to_string(),
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
/// A secret resource, encoded as `secret:DATABASE:NAMESPACE$SECRET`.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::{SecretObject, NamespacePath, Object};
|
||||
///
|
||||
/// let object = Object::Secret(SecretObject {
|
||||
/// database: "analytics".into(),
|
||||
/// namespace: NamespacePath::from_component("public"),
|
||||
/// secret: "example".into(),
|
||||
/// });
|
||||
/// assert_eq!(object.to_string(), "secret:analytics:public$example");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct SecretObject {
|
||||
pub database: String,
|
||||
pub namespace: NamespacePath,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
impl SecretObject {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
validate_secret_name(&self.secret).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SecretObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"secret:{}:{}${}",
|
||||
self.database, self.namespace, self.secret
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SecretObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SecretObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let invalid = || D::Error::custom(format!("invalid SecretObject {s:?}"));
|
||||
let rest = s.strip_prefix("secret:").ok_or_else(invalid)?;
|
||||
let (database, path) = rest.split_once(':').ok_or_else(invalid)?;
|
||||
let (namespace, secret) = path.rsplit_once('$').ok_or_else(invalid)?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
namespace: NamespacePath::parse(namespace).map_err(D::Error::custom)?,
|
||||
secret: secret.to_string(),
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// A function resource, encoded as `function:DATABASE:NAMESPACE$FUNCTION`.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::{FunctionObject, NamespacePath, Object};
|
||||
///
|
||||
/// let object = Object::Function(FunctionObject {
|
||||
/// database: "analytics".into(),
|
||||
/// namespace: NamespacePath::from_component("public"),
|
||||
/// function: "example".into(),
|
||||
/// });
|
||||
/// assert_eq!(object.to_string(), "function:analytics:public$example");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FunctionObject {
|
||||
pub database: String,
|
||||
pub namespace: NamespacePath,
|
||||
pub function: String,
|
||||
}
|
||||
|
||||
impl FunctionObject {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_database_name(&self.database).map_err(|error| error.to_string())?;
|
||||
validate_table_name(&self.function).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FunctionObject {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"function:{}:{}${}",
|
||||
self.database, self.namespace, self.function
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FunctionObject {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.validate().map_err(S::Error::custom)?;
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FunctionObject {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let invalid = || D::Error::custom(format!("invalid FunctionObject {s:?}"));
|
||||
let rest = s.strip_prefix("function:").ok_or_else(invalid)?;
|
||||
let (database, path) = rest.split_once(':').ok_or_else(invalid)?;
|
||||
let (namespace, function) = path.rsplit_once('$').ok_or_else(invalid)?;
|
||||
let object = Self {
|
||||
database: database.to_string(),
|
||||
namespace: NamespacePath::parse(namespace).map_err(D::Error::custom)?,
|
||||
function: function.to_string(),
|
||||
};
|
||||
object.validate().map_err(D::Error::custom)?;
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn system_object_round_trip() {
|
||||
let encoded = serde_json::to_string(&Object::System).unwrap();
|
||||
assert_eq!(encoded, r#""system""#);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Object>(&encoded).unwrap(),
|
||||
Object::System
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_database_round_trip() {
|
||||
let object = Object::Database(DatabaseObject {
|
||||
database: "db".to_string(),
|
||||
});
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""database:db""#);
|
||||
assert_eq!(serde_json::from_str::<Object>(&encoded).unwrap(), object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_namespace_round_trip() {
|
||||
let object = Object::Namespace(NamespaceObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
});
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""namespace:db:ns""#);
|
||||
assert_eq!(serde_json::from_str::<Object>(&encoded).unwrap(), object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_table_round_trip() {
|
||||
let object = Object::Table(TableObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
table: "foo".to_string(),
|
||||
});
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""table:db:ns$foo""#);
|
||||
assert_eq!(serde_json::from_str::<Object>(&encoded).unwrap(), object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_view_round_trip() {
|
||||
let object = Object::View(ViewObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
view: "foo".to_string(),
|
||||
});
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""view:db:ns$foo""#);
|
||||
assert_eq!(serde_json::from_str::<Object>(&encoded).unwrap(), object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_objects_round_trip_and_validate() {
|
||||
let resource = SecretObject {
|
||||
database: "tenant/db".into(),
|
||||
namespace: NamespacePath::new(vec!["a".into(), "b".into()]).unwrap(),
|
||||
secret: "example".into(),
|
||||
};
|
||||
let wire = "secret:tenant/db:a$b$example";
|
||||
assert_eq!(serde_json::to_value(&resource).unwrap(), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<SecretObject>(serde_json::json!(wire)).unwrap(),
|
||||
resource
|
||||
);
|
||||
let object = Object::Secret(resource.clone());
|
||||
assert_eq!(object.database(), Some("tenant/db"));
|
||||
assert_eq!(object.type_name(), "secret");
|
||||
assert_eq!(wire.parse::<Object>().unwrap(), object);
|
||||
assert_eq!(serde_json::to_value(object).unwrap(), wire);
|
||||
for name in ["", ".", "..", "bad$name", "bad/name", "bad:name"] {
|
||||
let invalid = SecretObject {
|
||||
secret: name.into(),
|
||||
..resource.clone()
|
||||
};
|
||||
assert!(serde_json::to_value(&invalid).is_err());
|
||||
assert!(serde_json::to_value(Object::Secret(invalid)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_objects_round_trip_and_validate() {
|
||||
let resource = FunctionObject {
|
||||
database: "tenant/db".into(),
|
||||
namespace: NamespacePath::new(vec!["a".into(), "b".into()]).unwrap(),
|
||||
function: "example".into(),
|
||||
};
|
||||
let wire = "function:tenant/db:a$b$example";
|
||||
assert_eq!(serde_json::to_value(&resource).unwrap(), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<FunctionObject>(serde_json::json!(wire)).unwrap(),
|
||||
resource
|
||||
);
|
||||
let object = Object::Function(resource.clone());
|
||||
assert_eq!(object.database(), Some("tenant/db"));
|
||||
assert_eq!(object.type_name(), "function");
|
||||
assert_eq!(wire.parse::<Object>().unwrap(), object);
|
||||
assert_eq!(serde_json::to_value(object).unwrap(), wire);
|
||||
for name in ["", ".", "..", "bad$name", "bad/name", "bad:name"] {
|
||||
let invalid = FunctionObject {
|
||||
function: name.into(),
|
||||
..resource.clone()
|
||||
};
|
||||
assert!(serde_json::to_value(&invalid).is_err());
|
||||
assert!(serde_json::to_value(Object::Function(invalid)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_objects_round_trip() {
|
||||
for wire in [
|
||||
"future:db:resource",
|
||||
"table_extension:db:resource",
|
||||
"garbage",
|
||||
] {
|
||||
let object: Object = wire.parse().unwrap();
|
||||
assert_eq!(object, Object::Unknown(wire.into()));
|
||||
assert_eq!(object.database(), None);
|
||||
assert_eq!(object.type_name(), UNKNOWN_TYPE_STRING);
|
||||
assert_eq!(object.to_string(), wire);
|
||||
assert_eq!(serde_json::to_value(&object).unwrap(), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Object>(serde_json::json!(wire)).unwrap(),
|
||||
object
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_object_round_trip() {
|
||||
let object = DatabaseObject {
|
||||
database: "db".to_string(),
|
||||
};
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""database:db""#);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DatabaseObject>(&encoded).unwrap(),
|
||||
object
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_object_round_trip() {
|
||||
let object = NamespaceObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
};
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""namespace:db:ns""#);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<NamespaceObject>(&encoded).unwrap(),
|
||||
object
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_object_rejects_missing_namespace() {
|
||||
assert!(serde_json::from_str::<NamespaceObject>(r#""namespace:db""#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_object_round_trip() {
|
||||
let object = TableObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
table: "foo".to_string(),
|
||||
};
|
||||
let encoded = serde_json::to_string(&object).unwrap();
|
||||
assert_eq!(encoded, r#""table:db:ns$foo""#);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TableObject>(&encoded).unwrap(),
|
||||
object
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_object_rejects_missing_prefix() {
|
||||
assert!(serde_json::from_str::<TableObject>(r#""foo""#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_object_rejects_missing_table() {
|
||||
assert!(serde_json::from_str::<TableObject>(r#""table:db:ns""#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_order_by_variant_then_path_components() {
|
||||
let database = Object::Database(DatabaseObject::new("z".to_string()));
|
||||
let namespace = Object::Namespace(NamespaceObject {
|
||||
database: "a".to_string(),
|
||||
namespace: NamespacePath::from_component("a"),
|
||||
});
|
||||
assert!(Object::System < database);
|
||||
assert!(database < namespace);
|
||||
|
||||
let first_table = Object::Table(TableObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("a"),
|
||||
table: "z".to_string(),
|
||||
});
|
||||
let second_table = Object::Table(TableObject {
|
||||
database: "db".to_string(),
|
||||
namespace: NamespacePath::from_component("b"),
|
||||
table: "a".to_string(),
|
||||
});
|
||||
assert!(first_table < second_table);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_namespace_paths_select_public() {
|
||||
let namespace = NamespacePath::new(vec![]).unwrap();
|
||||
assert_eq!(namespace, NamespacePath::default());
|
||||
assert_eq!(namespace.components(), &["public"]);
|
||||
assert!(namespace.parent().is_none());
|
||||
assert_eq!(
|
||||
namespace.prefixes().collect::<Vec<_>>(),
|
||||
vec![namespace.clone()]
|
||||
);
|
||||
|
||||
let database = "db".to_string();
|
||||
let objects = [
|
||||
Object::Namespace(NamespaceObject {
|
||||
database: database.clone(),
|
||||
namespace: namespace.clone(),
|
||||
}),
|
||||
Object::Table(TableObject {
|
||||
database: database.clone(),
|
||||
namespace: namespace.clone(),
|
||||
table: "item".into(),
|
||||
}),
|
||||
Object::View(ViewObject {
|
||||
database: database.clone(),
|
||||
namespace: namespace.clone(),
|
||||
view: "item".into(),
|
||||
}),
|
||||
Object::Secret(SecretObject {
|
||||
database: database.clone(),
|
||||
namespace: namespace.clone(),
|
||||
secret: "item".into(),
|
||||
}),
|
||||
Object::Function(FunctionObject {
|
||||
database,
|
||||
namespace,
|
||||
function: "item".into(),
|
||||
}),
|
||||
];
|
||||
for (object, wire) in objects.into_iter().zip([
|
||||
"namespace:db:public",
|
||||
"table:db:public$item",
|
||||
"view:db:public$item",
|
||||
"secret:db:public$item",
|
||||
"function:db:public$item",
|
||||
]) {
|
||||
assert_eq!(serde_json::to_value(&object).unwrap(), wire);
|
||||
assert_eq!(wire.parse::<Object>().unwrap(), object);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_path_returns_cumulative_prefixes() {
|
||||
let path = NamespacePath::new(vec!["ns1".into(), "ns2".into(), "ns3".into()]).unwrap();
|
||||
assert_eq!(
|
||||
path.prefixes()
|
||||
.map(|prefix| prefix.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["ns1", "ns1$ns2", "ns1$ns2$ns3"]
|
||||
);
|
||||
assert_eq!(path.parent().unwrap().to_string(), "ns1$ns2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_objects_round_trip_without_escaping() {
|
||||
for wire in [
|
||||
"database:tenant/db",
|
||||
"namespace:tenant/db:a$b",
|
||||
"table:tenant/db:a$b$c",
|
||||
"view:tenant/db:a$b$c",
|
||||
"secret:tenant/db:a$b$c",
|
||||
"function:tenant/db:a$b$c",
|
||||
] {
|
||||
let object: Object = serde_json::from_value(serde_json::json!(wire)).unwrap();
|
||||
assert_eq!(wire.parse::<Object>().unwrap(), object);
|
||||
assert_eq!(object.database(), Some("tenant/db"));
|
||||
assert_eq!(object.to_string(), wire);
|
||||
assert_eq!(serde_json::to_value(object).unwrap(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_reject_invalid_names_and_legacy_forms() {
|
||||
for database in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"db:bad",
|
||||
"db%2Fchild",
|
||||
"db$child",
|
||||
"/db",
|
||||
"db/",
|
||||
"db//child",
|
||||
"db/../child",
|
||||
"db name",
|
||||
] {
|
||||
for wire in [
|
||||
format!("database:{database}"),
|
||||
format!("namespace:{database}:ns"),
|
||||
format!("table:{database}:ns$t"),
|
||||
format!("view:{database}:ns$v"),
|
||||
format!("secret:{database}:ns$s"),
|
||||
format!("function:{database}:ns$f"),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Object>(serde_json::json!(wire)).is_err(),
|
||||
"{wire}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for name in ["", "bad:name", "bad/name", "bad%24name", "bad name"] {
|
||||
for wire in [
|
||||
format!("namespace:db:{name}"),
|
||||
format!("table:db:{name}$t"),
|
||||
format!("view:db:{name}$v"),
|
||||
format!("table:db:ns${name}"),
|
||||
format!("view:db:ns${name}"),
|
||||
format!("secret:db:{name}$s"),
|
||||
format!("function:db:{name}$f"),
|
||||
format!("secret:db:ns${name}"),
|
||||
format!("function:db:ns${name}"),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Object>(serde_json::json!(wire)).is_err(),
|
||||
"{wire}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for wire in [
|
||||
"",
|
||||
"system:db",
|
||||
"database",
|
||||
"namespace",
|
||||
"table",
|
||||
"view",
|
||||
"namespace:db/ns",
|
||||
"table:db/ns/t",
|
||||
"view:db/ns/v",
|
||||
"table:db:ns",
|
||||
"view:db:ns",
|
||||
"namespace:db:a$$b",
|
||||
"table:db:ns$.",
|
||||
"view:db:ns$..",
|
||||
"table:db:a$$t",
|
||||
"view:db:$v",
|
||||
"secret",
|
||||
"function",
|
||||
"secret:db/ns/s",
|
||||
"function:db/ns/f",
|
||||
"secret:db:ns",
|
||||
"function:db:ns",
|
||||
"secret:db:a$$s",
|
||||
"function:db:$f",
|
||||
"secret:db:ns$.",
|
||||
"function:db:ns$..",
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Object>(serde_json::json!(wire)).is_err(),
|
||||
"{wire}"
|
||||
);
|
||||
assert!(matches!(
|
||||
wire.parse::<Object>(),
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_components_use_namespace_validation() {
|
||||
for component in ["a$b", "c/d", "e%f", "a:b", "", ".", ".."] {
|
||||
assert!(NamespacePath::new(vec![component.into()]).is_err());
|
||||
}
|
||||
assert!(NamespacePath::new(vec!["a.b".into(), "c..d".into()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialization_rejects_invalid_directly_constructed_objects() {
|
||||
let database = DatabaseObject::new("db:bad".into());
|
||||
let namespace = NamespaceObject {
|
||||
database: "db%2Fbad".into(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
};
|
||||
let table = TableObject {
|
||||
database: "db".into(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
table: "bad$name".into(),
|
||||
};
|
||||
let view = ViewObject {
|
||||
database: "db".into(),
|
||||
namespace: NamespacePath::from_component("ns"),
|
||||
view: "..".into(),
|
||||
};
|
||||
assert!(serde_json::to_string(&database).is_err());
|
||||
assert!(serde_json::to_string(&namespace).is_err());
|
||||
assert!(serde_json::to_string(&table).is_err());
|
||||
assert!(serde_json::to_string(&view).is_err());
|
||||
for object in [
|
||||
Object::Database(database),
|
||||
Object::Namespace(namespace),
|
||||
Object::Table(table),
|
||||
Object::View(view),
|
||||
] {
|
||||
assert!(serde_json::to_string(&object).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
|
||||
/// A LanceDB privilege, serialized by name (for example, `SELECT`).
|
||||
/// Numeric aliases use `PRIVILEGE` followed by decimal digits. Numbers 0 through
|
||||
/// 13 map to the known privileges in declaration order. Unassigned numbers are
|
||||
/// preserved verbatim; their meaning and validity are determined by the server.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::Privilege;
|
||||
///
|
||||
/// assert_eq!("PRIVILEGE0".parse::<Privilege>().unwrap(), Privilege::Ownership);
|
||||
/// assert_eq!("PRIVILEGE2".parse::<Privilege>().unwrap(), Privilege::Select);
|
||||
///
|
||||
/// let privilege: Privilege = "PRIVILEGE256".parse().unwrap();
|
||||
/// assert_eq!(privilege.to_string(), "PRIVILEGE256");
|
||||
///
|
||||
/// // Delegate creation on a namespace without transferring its ownership.
|
||||
/// for privilege in [Privilege::CreateSecret, Privilege::CreateFunction] {
|
||||
/// assert!(privilege.valid_for_namespace());
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, strum_macros::Display)]
|
||||
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[non_exhaustive]
|
||||
pub enum Privilege {
|
||||
/// A special privilege that conveys ownership.
|
||||
Ownership,
|
||||
|
||||
/// On a database: allows accessing the namespaces inside.
|
||||
/// On a namespace: allows accessing the tables inside.
|
||||
///
|
||||
/// When looking for Usage on a Database, if there are no ACLs
|
||||
/// found for that database, we assume it doesn't exist yet and
|
||||
/// check for CreateDatabase on System.
|
||||
///
|
||||
/// When looking for Usage on a public (aka default) Namespace,
|
||||
/// if there are no ACLs found for that Namespace, we assume it
|
||||
/// doesn't exist yet and check for CreateNamespace on the
|
||||
/// containing database instead.
|
||||
Usage,
|
||||
|
||||
/// On a table: allows reading rows.
|
||||
Select,
|
||||
|
||||
/// On a table: allows appending rows.
|
||||
Insert,
|
||||
|
||||
/// On a table: allows modifying existing rows.
|
||||
Update,
|
||||
|
||||
/// On a table: allows deleting rows.
|
||||
Delete,
|
||||
|
||||
/// On the system object: can create a database.
|
||||
CreateDatabase,
|
||||
|
||||
/// On a database or namespace: can create immediate child namespaces.
|
||||
CreateNamespace,
|
||||
|
||||
/// On a namespace: can create a table in that namespace.
|
||||
CreateTable,
|
||||
|
||||
/// On a namespace: can create a materialized view in that namespace.
|
||||
CreateMaterializedView,
|
||||
|
||||
/// On a namespace: can create a view in that namespace.
|
||||
CreateView,
|
||||
|
||||
/// On a namespace: can create a secret without taking ownership of the namespace.
|
||||
CreateSecret,
|
||||
|
||||
/// On a namespace: can create a function without taking ownership of the namespace.
|
||||
CreateFunction,
|
||||
|
||||
/// On the system object:
|
||||
/// - can read server configuration.
|
||||
Operate,
|
||||
|
||||
/// A future privilege name, preserved verbatim.
|
||||
#[strum(to_string = "{0}")]
|
||||
Unknown(UnknownPrivilege),
|
||||
}
|
||||
|
||||
/// An unknown privilege.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnknownPrivilege(String);
|
||||
|
||||
impl fmt::Display for UnknownPrivilege {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Privilege {
|
||||
pub const KNOWN: [Self; 14] = [
|
||||
Self::Ownership,
|
||||
Self::Usage,
|
||||
Self::Select,
|
||||
Self::Insert,
|
||||
Self::Update,
|
||||
Self::Delete,
|
||||
Self::CreateDatabase,
|
||||
Self::CreateNamespace,
|
||||
Self::CreateTable,
|
||||
Self::CreateMaterializedView,
|
||||
Self::CreateView,
|
||||
Self::CreateSecret,
|
||||
Self::CreateFunction,
|
||||
Self::Operate,
|
||||
];
|
||||
|
||||
pub fn valid_for_system(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Operate | Self::CreateDatabase | Self::Usage | Self::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn valid_for_database(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Ownership | Self::Usage | Self::CreateNamespace | Self::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn valid_for_namespace(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Ownership
|
||||
| Self::Usage
|
||||
| Self::CreateNamespace
|
||||
| Self::CreateTable
|
||||
| Self::CreateMaterializedView
|
||||
| Self::CreateView
|
||||
| Self::CreateSecret
|
||||
| Self::CreateFunction
|
||||
| Self::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn valid_for_table(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Ownership
|
||||
| Self::Select
|
||||
| Self::Insert
|
||||
| Self::Update
|
||||
| Self::Delete
|
||||
| Self::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn valid_for_view(&self) -> bool {
|
||||
matches!(self, Self::Ownership | Self::Select | Self::Unknown(_))
|
||||
}
|
||||
|
||||
pub fn valid_for_secret(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Ownership | Self::Usage | Self::Update | Self::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn valid_for_function(&self) -> bool {
|
||||
matches!(self, Self::Ownership | Self::Usage | Self::Unknown(_))
|
||||
}
|
||||
|
||||
/// Whether this privilege can mutate database contents or schema.
|
||||
/// Unknown privileges are conservatively treated as writes.
|
||||
pub fn is_write(&self) -> bool {
|
||||
!matches!(self, Self::Usage | Self::Select | Self::Operate)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Privilege {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(name: &str) -> Result<Self, Self::Err> {
|
||||
if let Some(privilege) = Self::KNOWN.into_iter().find(|p| p.to_string() == name) {
|
||||
return Ok(privilege);
|
||||
}
|
||||
if let Some(digits) = name.strip_prefix("PRIVILEGE")
|
||||
&& !digits.is_empty()
|
||||
&& digits.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
return Ok(match digits.parse::<u64>() {
|
||||
Ok(0) => Self::Ownership,
|
||||
Ok(1) => Self::Usage,
|
||||
Ok(2) => Self::Select,
|
||||
Ok(3) => Self::Insert,
|
||||
Ok(4) => Self::Update,
|
||||
Ok(5) => Self::Delete,
|
||||
Ok(6) => Self::CreateDatabase,
|
||||
Ok(7) => Self::CreateNamespace,
|
||||
Ok(8) => Self::CreateTable,
|
||||
Ok(9) => Self::CreateMaterializedView,
|
||||
Ok(10) => Self::CreateView,
|
||||
Ok(11) => Self::CreateSecret,
|
||||
Ok(12) => Self::CreateFunction,
|
||||
Ok(13) => Self::Operate,
|
||||
_ => Self::Unknown(UnknownPrivilege(name.to_owned())),
|
||||
});
|
||||
}
|
||||
Err(format!("invalid privilege {name:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a privilege to its numeric identifier.
|
||||
///
|
||||
/// Unknown privileges return an error if their number exceeds [`u8::MAX`].
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::Privilege;
|
||||
///
|
||||
/// assert_eq!(u8::try_from(&Privilege::Select).unwrap(), 2);
|
||||
/// let unknown: Privilege = "PRIVILEGE255".parse().unwrap();
|
||||
/// assert_eq!(u8::try_from(&unknown).unwrap(), 255);
|
||||
/// let too_large: Privilege = "PRIVILEGE256".parse().unwrap();
|
||||
/// assert!(u8::try_from(&too_large).is_err());
|
||||
/// ```
|
||||
impl TryFrom<&Privilege> for u8 {
|
||||
type Error = std::num::ParseIntError;
|
||||
|
||||
fn try_from(privilege: &Privilege) -> Result<Self, Self::Error> {
|
||||
Ok(match privilege {
|
||||
Privilege::Ownership => 0,
|
||||
Privilege::Usage => 1,
|
||||
Privilege::Select => 2,
|
||||
Privilege::Insert => 3,
|
||||
Privilege::Update => 4,
|
||||
Privilege::Delete => 5,
|
||||
Privilege::CreateDatabase => 6,
|
||||
Privilege::CreateNamespace => 7,
|
||||
Privilege::CreateTable => 8,
|
||||
Privilege::CreateMaterializedView => 9,
|
||||
Privilege::CreateView => 10,
|
||||
Privilege::CreateSecret => 11,
|
||||
Privilege::CreateFunction => 12,
|
||||
Privilege::Operate => 13,
|
||||
Privilege::Unknown(unknown) => unknown.0["PRIVILEGE".len()..].parse()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an owned privilege to its numeric identifier.
|
||||
///
|
||||
/// Like the borrowed conversion, this returns an error for numbers above 255.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::Privilege;
|
||||
///
|
||||
/// assert_eq!(u8::try_from(Privilege::Ownership).unwrap(), 0);
|
||||
/// ```
|
||||
impl TryFrom<Privilege> for u8 {
|
||||
type Error = std::num::ParseIntError;
|
||||
|
||||
fn try_from(privilege: Privilege) -> Result<Self, Self::Error> {
|
||||
Self::try_from(&privilege)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Privilege {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Privilege {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer)?
|
||||
.parse()
|
||||
.map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Privilege;
|
||||
|
||||
#[test]
|
||||
fn display_matches_serde() {
|
||||
for privilege in Privilege::KNOWN {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&privilege).unwrap(),
|
||||
format!("\"{privilege}\""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_and_function_creation_privileges() {
|
||||
for (name, privilege) in [
|
||||
("CREATE_SECRET", Privilege::CreateSecret),
|
||||
("CREATE_FUNCTION", Privilege::CreateFunction),
|
||||
] {
|
||||
assert_eq!(name.parse::<Privilege>().unwrap(), privilege);
|
||||
assert_eq!(privilege.to_string(), name);
|
||||
assert_eq!(serde_json::to_value(&privilege).unwrap(), name);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Privilege>(serde_json::json!(name)).unwrap(),
|
||||
privilege
|
||||
);
|
||||
assert!(privilege.valid_for_namespace());
|
||||
assert!(!privilege.valid_for_system());
|
||||
assert!(!privilege.valid_for_database());
|
||||
assert!(!privilege.valid_for_table());
|
||||
assert!(!privilege.valid_for_view());
|
||||
assert!(privilege.is_write());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_aliases_resolve_to_known_privileges() {
|
||||
for (number, privilege) in [
|
||||
(0, Privilege::Ownership),
|
||||
(1, Privilege::Usage),
|
||||
(2, Privilege::Select),
|
||||
(3, Privilege::Insert),
|
||||
(4, Privilege::Update),
|
||||
(5, Privilege::Delete),
|
||||
(6, Privilege::CreateDatabase),
|
||||
(7, Privilege::CreateNamespace),
|
||||
(8, Privilege::CreateTable),
|
||||
(9, Privilege::CreateMaterializedView),
|
||||
(10, Privilege::CreateView),
|
||||
(11, Privilege::CreateSecret),
|
||||
(12, Privilege::CreateFunction),
|
||||
(13, Privilege::Operate),
|
||||
] {
|
||||
assert_eq!(u8::try_from(&privilege).unwrap(), number);
|
||||
assert_eq!(u8::try_from(privilege.clone()).unwrap(), number);
|
||||
for name in [
|
||||
format!("PRIVILEGE{number}"),
|
||||
format!("PRIVILEGE{number:03}"),
|
||||
] {
|
||||
let parsed = name.parse::<Privilege>().unwrap();
|
||||
assert_eq!(parsed, privilege, "{name}");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Privilege>(serde_json::json!(name)).unwrap(),
|
||||
privilege
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&parsed).unwrap(),
|
||||
privilege.to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
privilege.to_string().parse::<Privilege>().unwrap(),
|
||||
privilege
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_privileges_convert_to_u8() {
|
||||
for number in 14..=u8::MAX {
|
||||
for name in [
|
||||
format!("PRIVILEGE{number}"),
|
||||
format!("PRIVILEGE{number:03}"),
|
||||
] {
|
||||
let privilege: Privilege = name.parse().unwrap();
|
||||
assert!(matches!(privilege, Privilege::Unknown(_)));
|
||||
assert_eq!(u8::try_from(&privilege).unwrap(), number);
|
||||
assert_eq!(u8::try_from(privilege).unwrap(), number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privileges_outside_u8_range_fail_conversion() {
|
||||
for name in [
|
||||
"PRIVILEGE256",
|
||||
"PRIVILEGE0256",
|
||||
"PRIVILEGE18446744073709551616",
|
||||
] {
|
||||
let privilege: Privilege = name.parse().unwrap();
|
||||
assert_eq!(
|
||||
u8::try_from(&privilege).unwrap_err().kind(),
|
||||
&std::num::IntErrorKind::PosOverflow
|
||||
);
|
||||
assert_eq!(
|
||||
u8::try_from(privilege).unwrap_err().kind(),
|
||||
&std::num::IntErrorKind::PosOverflow
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_privilege_names_round_trip() {
|
||||
for name in [
|
||||
"PRIVILEGE14",
|
||||
"PRIVILEGE20",
|
||||
"PRIVILEGE64",
|
||||
"PRIVILEGE256",
|
||||
"PRIVILEGE18446744073709551616",
|
||||
"PRIVILEGE020",
|
||||
] {
|
||||
let privilege: Privilege = name.parse().unwrap();
|
||||
assert!(matches!(privilege, Privilege::Unknown(_)));
|
||||
assert_eq!(privilege.to_string(), name);
|
||||
assert_eq!(serde_json::to_value(&privilege).unwrap(), name);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Privilege>(serde_json::json!(name)).unwrap(),
|
||||
privilege
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_privileges() {
|
||||
for name in [
|
||||
"",
|
||||
"select",
|
||||
" SELECT",
|
||||
"SELECT ",
|
||||
"PRIVILEGE",
|
||||
"PRIVILEGE-1",
|
||||
"PRIVILEGE+1",
|
||||
"PRIVILEGE 1",
|
||||
"PRIVILEGE1x",
|
||||
"privilege12",
|
||||
"UNKNOWN",
|
||||
] {
|
||||
assert!(name.parse::<Privilege>().is_err(), "{name}");
|
||||
assert!(
|
||||
serde_json::from_value::<Privilege>(serde_json::json!(name)).is_err(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_privileges_are_valid_for_all_objects_and_treated_as_writes() {
|
||||
for name in ["PRIVILEGE14", "PRIVILEGE20", "PRIVILEGE64", "PRIVILEGE256"] {
|
||||
let privilege: Privilege = name.parse().unwrap();
|
||||
assert!(privilege.valid_for_system());
|
||||
assert!(privilege.valid_for_database());
|
||||
assert!(privilege.valid_for_namespace());
|
||||
assert!(privilege.valid_for_table());
|
||||
assert!(privilege.valid_for_view());
|
||||
assert!(privilege.valid_for_secret());
|
||||
assert!(privilege.valid_for_function());
|
||||
assert!(privilege.is_write());
|
||||
}
|
||||
assert!(!Privilege::Ownership.valid_for_system());
|
||||
assert!(!Privilege::Select.valid_for_database());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_privilege_is_write() {
|
||||
for privilege in Privilege::KNOWN {
|
||||
assert_eq!(
|
||||
privilege.is_write(),
|
||||
matches!(
|
||||
privilege,
|
||||
Privilege::Ownership
|
||||
| Privilege::Insert
|
||||
| Privilege::Update
|
||||
| Privilege::Delete
|
||||
| Privilege::CreateDatabase
|
||||
| Privilege::CreateNamespace
|
||||
| Privilege::CreateTable
|
||||
| Privilege::CreateMaterializedView
|
||||
| Privilege::CreateView
|
||||
| Privilege::CreateSecret
|
||||
| Privilege::CreateFunction
|
||||
),
|
||||
"unexpected write classification for {privilege}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_secret_and_create_function_are_namespace_scoped() {
|
||||
for privilege in [Privilege::CreateSecret, Privilege::CreateFunction] {
|
||||
assert!(privilege.valid_for_namespace());
|
||||
assert!(!privilege.valid_for_system());
|
||||
assert!(!privilege.valid_for_database());
|
||||
assert!(!privilege.valid_for_table());
|
||||
assert!(!privilege.valid_for_view());
|
||||
assert!(!privilege.valid_for_secret());
|
||||
assert!(!privilege.valid_for_function());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_and_function_privileges() {
|
||||
assert_eq!(
|
||||
Privilege::KNOWN
|
||||
.into_iter()
|
||||
.filter(Privilege::valid_for_secret)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Privilege::Ownership, Privilege::Usage, Privilege::Update]
|
||||
);
|
||||
assert_eq!(
|
||||
Privilege::KNOWN
|
||||
.into_iter()
|
||||
.filter(Privilege::valid_for_function)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Privilege::Ownership, Privilege::Usage]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Identity selectors. Provider names resolve on the server; IDs remain literal.
|
||||
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The kind of identifier supplied to an authorization operation.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum SubjectKind {
|
||||
PrincipalId,
|
||||
PrincipalName,
|
||||
GroupId,
|
||||
GroupName,
|
||||
Role,
|
||||
PrincipalApiKey,
|
||||
/// An unrecognized canonical subject. Its full wire string is stored as the value.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl SubjectKind {
|
||||
/// Stable name used by language bindings.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PrincipalId => "principal_id",
|
||||
Self::PrincipalName => "principal_name",
|
||||
Self::GroupId => "group_id",
|
||||
Self::GroupName => "group_name",
|
||||
Self::Role => "role",
|
||||
Self::PrincipalApiKey => "principal_api_key",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::PrincipalId => "p:",
|
||||
Self::PrincipalName => "P:",
|
||||
Self::GroupId => "g:",
|
||||
Self::GroupName => "G:",
|
||||
Self::Role => "r:",
|
||||
Self::PrincipalApiKey => "a:",
|
||||
Self::Unknown => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const KINDS: [SubjectKind; 6] = [
|
||||
SubjectKind::PrincipalId,
|
||||
SubjectKind::PrincipalName,
|
||||
SubjectKind::GroupId,
|
||||
SubjectKind::GroupName,
|
||||
SubjectKind::Role,
|
||||
SubjectKind::PrincipalApiKey,
|
||||
];
|
||||
|
||||
impl FromStr for SubjectKind {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
if value == "unknown" {
|
||||
return Ok(Self::Unknown);
|
||||
}
|
||||
KINDS
|
||||
.into_iter()
|
||||
.find(|kind| kind.as_str() == value)
|
||||
.ok_or_else(|| invalid("Invalid subject kind"))
|
||||
}
|
||||
}
|
||||
|
||||
/// An immutable validated principal, group, or role selector.
|
||||
///
|
||||
/// Unrecognized canonical prefixes are preserved as [`SubjectKind::Unknown`],
|
||||
/// with the complete wire string in [`Self::value`], so future subject kinds can
|
||||
/// be listed and sent back to the server.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::{Subject, SubjectKind};
|
||||
/// let subject = Subject::from_canonical("x:future-subject")?;
|
||||
/// assert_eq!(subject.kind(), SubjectKind::Unknown);
|
||||
/// assert_eq!(subject.to_wire(), "x:future-subject");
|
||||
/// # Ok::<(), lancedb::Error>(())
|
||||
/// ```
|
||||
///
|
||||
/// Debug and Display redact API keys. [`Self::to_wire`] and serialization
|
||||
/// intentionally expose the key for transport and must not be logged.
|
||||
///
|
||||
/// ```
|
||||
/// use lancedb::authz::Subject;
|
||||
/// let subject = Subject::group_name("Engineering")?;
|
||||
/// assert_eq!(subject.to_wire(), "G:Engineering");
|
||||
/// assert_eq!(subject, "G:Engineering".parse::<Subject>()?);
|
||||
/// # Ok::<(), lancedb::Error>(())
|
||||
/// ```
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Subject {
|
||||
kind: SubjectKind,
|
||||
value: String,
|
||||
}
|
||||
|
||||
fn invalid(message: &str) -> Error {
|
||||
// Never include the selector: it may contain an API key.
|
||||
Error::InvalidInput {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
/// Construct a selector without resolving it against an identity provider.
|
||||
pub fn new(kind: SubjectKind, value: impl Into<String>) -> Result<Self> {
|
||||
let value = value.into();
|
||||
if value.is_empty() {
|
||||
return Err(invalid("Subject value must be a nonempty string"));
|
||||
}
|
||||
if kind == SubjectKind::Unknown
|
||||
&& (!value
|
||||
.split_once(':')
|
||||
.is_some_and(|(prefix, value)| !prefix.is_empty() && !value.is_empty())
|
||||
|| KINDS.iter().any(|kind| value.starts_with(kind.prefix())))
|
||||
{
|
||||
return Err(invalid("Invalid unknown subject selector"));
|
||||
}
|
||||
Ok(Self { kind, value })
|
||||
}
|
||||
|
||||
/// Select a principal by canonical ID, without stripping any prefix in the ID.
|
||||
pub fn principal_id(id: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::PrincipalId, id)
|
||||
}
|
||||
/// Select a principal by an unambiguous provider name.
|
||||
pub fn principal_name(name: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::PrincipalName, name)
|
||||
}
|
||||
/// Select a group by canonical ID.
|
||||
pub fn group_id(id: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::GroupId, id)
|
||||
}
|
||||
/// Select a group by an unambiguous provider name.
|
||||
pub fn group_name(name: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::GroupName, name)
|
||||
}
|
||||
/// Select a LanceDB-managed role.
|
||||
pub fn role(name: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::Role, name)
|
||||
}
|
||||
/// Select the principal holding this secret API key, not the request's caller.
|
||||
pub fn principal_api_key(key: impl Into<String>) -> Result<Self> {
|
||||
Self::new(SubjectKind::PrincipalApiKey, key)
|
||||
}
|
||||
|
||||
/// The selector kind.
|
||||
pub fn kind(&self) -> SubjectKind {
|
||||
self.kind
|
||||
}
|
||||
/// The selected ID, name, role, or API key. API-key values are secrets.
|
||||
/// For unknown kinds, this is the full canonical wire string.
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
/// The value safe for display; API keys are replaced with `<redacted>`.
|
||||
pub fn display_value(&self) -> &str {
|
||||
if self.kind == SubjectKind::PrincipalApiKey {
|
||||
"<redacted>"
|
||||
} else {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
/// Encode the literal wire selector. The result can contain a secret API key.
|
||||
pub fn to_wire(&self) -> String {
|
||||
format!("{}{}", self.kind.prefix(), self.value)
|
||||
}
|
||||
|
||||
/// Parse a returned canonical identity, rejecting names and secret selectors.
|
||||
pub fn from_canonical(value: &str) -> Result<Self> {
|
||||
let subject: Self = value.parse()?;
|
||||
if !matches!(
|
||||
subject.kind,
|
||||
SubjectKind::PrincipalId
|
||||
| SubjectKind::GroupId
|
||||
| SubjectKind::Role
|
||||
| SubjectKind::Unknown
|
||||
) {
|
||||
return Err(invalid("Server returned a non-canonical subject"));
|
||||
}
|
||||
Ok(subject)
|
||||
}
|
||||
|
||||
/// Deserialize a canonical response subject, rejecting secrets and name selectors.
|
||||
pub fn deserialize_canonical<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Self, D::Error> {
|
||||
Self::from_canonical(&String::deserialize(deserializer)?).map_err(D::Error::custom)
|
||||
}
|
||||
|
||||
/// Require a principal selector for a principal lookup.
|
||||
pub fn validate_principal(&self) -> Result<()> {
|
||||
if matches!(
|
||||
self.kind,
|
||||
SubjectKind::PrincipalId | SubjectKind::PrincipalName | SubjectKind::PrincipalApiKey
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid("Subject kind is not valid for a principal lookup"))
|
||||
}
|
||||
}
|
||||
/// Require a group selector for a group lookup.
|
||||
pub fn validate_group(&self) -> Result<()> {
|
||||
if matches!(self.kind, SubjectKind::GroupId | SubjectKind::GroupName) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid("Subject kind is not valid for a group lookup"))
|
||||
}
|
||||
}
|
||||
/// Require a principal/group selector. Roles cannot contain other roles.
|
||||
pub fn validate_role_member(&self) -> Result<()> {
|
||||
if self.kind == SubjectKind::Role {
|
||||
Err(invalid("Roles cannot be members of other roles"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Subject {
|
||||
type Err = Error;
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
for kind in KINDS {
|
||||
if let Some(value) = value.strip_prefix(kind.prefix()) {
|
||||
return Self::new(kind, value);
|
||||
}
|
||||
}
|
||||
Self::new(SubjectKind::Unknown, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Subject {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Subject")
|
||||
.field("kind", &self.kind)
|
||||
.field("value", &self.display_value())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
impl fmt::Display for Subject {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}{}", self.kind.prefix(), self.display_value())
|
||||
}
|
||||
}
|
||||
impl Serialize for Subject {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_wire())
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for Subject {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
String::deserialize(deserializer)?
|
||||
.parse()
|
||||
.map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selectors_roundtrip_without_normalizing_values() {
|
||||
for kind in KINDS {
|
||||
for value in [
|
||||
"alice@example.com",
|
||||
"p:literal-id",
|
||||
"Engineering / R&D",
|
||||
"名前",
|
||||
] {
|
||||
let subject = Subject::new(kind, value).unwrap();
|
||||
assert_eq!(subject.to_wire().parse::<Subject>().unwrap(), subject);
|
||||
let json = serde_json::to_string(&subject).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Subject>(&json).unwrap(), subject);
|
||||
assert_eq!(subject.value(), value);
|
||||
assert!(Subject::new(kind, "").is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contexts_and_canonical_ids() {
|
||||
for kind in KINDS {
|
||||
let subject = Subject::new(kind, "value").unwrap();
|
||||
assert_eq!(
|
||||
subject.validate_principal().is_ok(),
|
||||
matches!(
|
||||
kind,
|
||||
SubjectKind::PrincipalId
|
||||
| SubjectKind::PrincipalName
|
||||
| SubjectKind::PrincipalApiKey
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
subject.validate_group().is_ok(),
|
||||
matches!(kind, SubjectKind::GroupId | SubjectKind::GroupName)
|
||||
);
|
||||
assert_eq!(
|
||||
subject.validate_role_member().is_ok(),
|
||||
kind != SubjectKind::Role
|
||||
);
|
||||
assert_eq!(
|
||||
Subject::from_canonical(&subject.to_wire()).is_ok(),
|
||||
matches!(
|
||||
kind,
|
||||
SubjectKind::PrincipalId | SubjectKind::GroupId | SubjectKind::Role
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subjects_preserve_canonical_wire_strings() {
|
||||
for wire in ["x:future-subject", "future:literal:identifier"] {
|
||||
let subject = Subject::from_canonical(wire).unwrap();
|
||||
assert_eq!(subject.kind(), SubjectKind::Unknown);
|
||||
assert_eq!(subject.value(), wire);
|
||||
assert_eq!(subject.to_wire(), wire);
|
||||
assert_eq!(serde_json::to_value(&subject).unwrap(), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Subject>(serde_json::json!(wire)).unwrap(),
|
||||
subject
|
||||
);
|
||||
assert!(subject.validate_principal().is_err());
|
||||
assert!(subject.validate_group().is_err());
|
||||
}
|
||||
for wire in [
|
||||
"",
|
||||
"no-prefix",
|
||||
":value",
|
||||
"x:",
|
||||
"p:value",
|
||||
"P:value",
|
||||
"g:value",
|
||||
"G:value",
|
||||
"r:value",
|
||||
"a:secret",
|
||||
] {
|
||||
assert!(Subject::new(SubjectKind::Unknown, wire).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acl_page_preserves_known_and_unknown_subjects() {
|
||||
let payload = serde_json::json!({"entries": [
|
||||
{"subject_id": "p:known", "object": "system", "privilege": "USAGE"},
|
||||
{"subject_id": "x:future-subject", "object": "system", "privilege": "USAGE"}
|
||||
]});
|
||||
let page: crate::authz::ListAccessControlEntriesResponse =
|
||||
serde_json::from_value(payload.clone()).unwrap();
|
||||
assert_eq!(page.entries.len(), 2);
|
||||
assert_eq!(page.entries[0].subject_id.kind(), SubjectKind::PrincipalId);
|
||||
assert_eq!(page.entries[1].subject_id.kind(), SubjectKind::Unknown);
|
||||
assert_eq!(serde_json::to_value(page).unwrap(), payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_only_exposed_by_explicit_serialization() {
|
||||
let secret = "never-log-this-key";
|
||||
let subject = Subject::principal_api_key(secret).unwrap();
|
||||
assert!(!format!("{subject:?} {subject}").contains(secret));
|
||||
assert!(serde_json::to_string(&subject).unwrap().contains(secret));
|
||||
let error = Subject::from_canonical(&subject.to_wire()).unwrap_err();
|
||||
assert!(!error.to_string().contains(secret));
|
||||
assert!(!format!("{error:?}").contains(secret));
|
||||
let error = secret.parse::<Subject>().unwrap_err();
|
||||
assert!(!error.to_string().contains(secret));
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,13 @@ impl Stream for DatabaseNames {
|
||||
/// A backend that manages databases. Implementations own database lifecycle semantics.
|
||||
#[async_trait::async_trait]
|
||||
pub trait Catalog: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Obtain deployment-wide authorization, if supported by this backend.
|
||||
fn authz(&self) -> Result<Arc<dyn crate::authz::Authorization>> {
|
||||
Err(crate::Error::NotSupported {
|
||||
message: "Authorization administration is not supported by this catalog".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Catalog endpoint or location.
|
||||
fn uri(&self) -> &str;
|
||||
/// Create a database, or open it when `exist_ok` permits.
|
||||
@@ -239,6 +246,11 @@ impl CatalogConnection {
|
||||
pub fn uri(&self) -> &str {
|
||||
self.catalog.uri()
|
||||
}
|
||||
/// Obtain a handle sharing this catalog's deployment scope and credentials.
|
||||
pub fn authz(&self) -> Result<Arc<dyn crate::authz::Authorization>> {
|
||||
self.catalog.authz()
|
||||
}
|
||||
|
||||
/// Access the underlying backend.
|
||||
pub fn catalog(&self) -> &Arc<dyn Catalog> {
|
||||
&self.catalog
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
pub mod arrow;
|
||||
pub mod authz;
|
||||
pub mod blob;
|
||||
pub mod catalog;
|
||||
pub mod connection;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! building client/server applications with LanceDB or as a client for some
|
||||
//! other custom LanceDB service.
|
||||
|
||||
mod authz;
|
||||
pub mod catalog;
|
||||
pub(crate) mod client;
|
||||
pub(crate) mod db;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use super::client::{HttpSend, RequestResultExt, RestfulLanceDbClient, Sender};
|
||||
use crate::Result;
|
||||
use crate::authz::*;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
pub struct RemoteAuthorization<S: HttpSend = Sender> {
|
||||
pub(crate) client: RestfulLanceDbClient<S>,
|
||||
}
|
||||
|
||||
impl<S: HttpSend> std::fmt::Debug for RemoteAuthorization<S> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RemoteAuthorization")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: HttpSend> RemoteAuthorization<S> {
|
||||
async fn post<T: Serialize + Sync>(
|
||||
&self,
|
||||
route: &str,
|
||||
request: &T,
|
||||
) -> Result<(String, reqwest::Response)> {
|
||||
// Structured objects can be constructed with invalid names. Serialize
|
||||
// before building the request so validation errors remain local errors.
|
||||
let body = serde_json::to_value(request).map_err(|error| crate::Error::InvalidInput {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let request = self
|
||||
.client
|
||||
.post(&format!("/admin/authz/{route}"))
|
||||
.json(&body);
|
||||
// Every subject selector can contain a secret. Never log these bodies,
|
||||
// and never replay an ACL mutation after an ambiguous response.
|
||||
let (id, response) = self.client.send_suppressing_body(request).await?;
|
||||
let response = self.client.check_response(&id, response).await?;
|
||||
Ok((id, response))
|
||||
}
|
||||
|
||||
async fn read<T: Serialize + Sync, R: DeserializeOwned>(
|
||||
&self,
|
||||
route: &str,
|
||||
request: &T,
|
||||
) -> Result<R> {
|
||||
let (id, response) = self.post(route, request).await?;
|
||||
response.json().await.err_to_http(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<S: HttpSend> Authorization for RemoteAuthorization<S> {
|
||||
async fn list_acls(
|
||||
&self,
|
||||
request: ListAccessControlEntriesRequest,
|
||||
) -> Result<ListAccessControlEntriesResponse> {
|
||||
self.read("acl/list", &request).await
|
||||
}
|
||||
async fn add_acl(&self, request: AccessControlEntryRequest) -> Result<()> {
|
||||
self.post("acl/add", &request).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_acl(&self, request: AccessControlEntryRequest) -> Result<()> {
|
||||
self.post("acl/delete", &request).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn list_role_bindings(
|
||||
&self,
|
||||
request: ListRoleBindingsRequest,
|
||||
) -> Result<ListRoleBindingsResponse> {
|
||||
request.validate()?;
|
||||
self.read("role_binding/list", &request).await
|
||||
}
|
||||
async fn add_role_binding(&self, request: RoleBindingRequest) -> Result<()> {
|
||||
request.validate()?;
|
||||
self.post("role_binding/add", &request).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_role_binding(&self, request: RoleBindingRequest) -> Result<()> {
|
||||
request.validate()?;
|
||||
self.post("role_binding/delete", &request).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn get_principal(&self, request: GetPrincipalRequest) -> Result<PrincipalInfo> {
|
||||
request.validate()?;
|
||||
self.read("principal", &request).await
|
||||
}
|
||||
async fn get_group(&self, request: GetGroupRequest) -> Result<GroupInfo> {
|
||||
request.validate()?;
|
||||
self.read("group", &request).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::remote::client::test_utils::client_with_handler;
|
||||
use serde_json::json;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_preserves_wire_identity_and_continuation() {
|
||||
let authz = RemoteAuthorization {
|
||||
client: client_with_handler(|request| {
|
||||
assert_eq!(request.url().path(), "/admin/authz/acl/list");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({"object": "table:tenant/db:a$b$events", "subject": "G:Engineering", "max_results": 100, "continuation_token": "opaque/token"})
|
||||
);
|
||||
http::Response::builder().status(200).body(json!({
|
||||
"entries": [{"object": "table:tenant/db:a$b$events", "subject_id": "g:team-id", "privilege": "PRIVILEGE20"}],
|
||||
"continuation_token": "next/token"
|
||||
}).to_string()).unwrap()
|
||||
}),
|
||||
};
|
||||
let result = authz
|
||||
.list_acls(ListAccessControlEntriesRequest {
|
||||
object: Some("table:tenant/db:a$b$events".parse().unwrap()),
|
||||
subject: Some(Subject::group_name("Engineering").unwrap()),
|
||||
max_results: Some(100),
|
||||
continuation_token: Some("opaque/token".into()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.continuation_token.as_deref(), Some("next/token"));
|
||||
assert_eq!(
|
||||
result.entries[0].subject_id,
|
||||
Subject::group_id("team-id").unwrap()
|
||||
);
|
||||
assert_eq!(result.entries[0].subject_name, None);
|
||||
assert_eq!(
|
||||
result.entries[0].privilege,
|
||||
"PRIVILEGE20".parse::<Privilege>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acl_failure_is_not_retried_or_reclassified() {
|
||||
for status in [404, 409, 503] {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let count = calls.clone();
|
||||
let authz = RemoteAuthorization {
|
||||
client: client_with_handler(move |request| {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(body["privilege"], "USAGE");
|
||||
http::Response::builder()
|
||||
.status(status)
|
||||
.body("failure")
|
||||
.unwrap()
|
||||
}),
|
||||
};
|
||||
let result = authz
|
||||
.add_acl(AccessControlEntryRequest {
|
||||
object: Object::Database(DatabaseObject::new("db".into())),
|
||||
subject: Subject::principal_api_key("secret").unwrap(),
|
||||
privilege: Privilege::Usage,
|
||||
})
|
||||
.await;
|
||||
let Err(crate::Error::Http {
|
||||
status_code,
|
||||
request_id,
|
||||
..
|
||||
}) = result
|
||||
else {
|
||||
panic!("expected Http error")
|
||||
};
|
||||
assert_eq!(status_code.unwrap().as_u16(), status);
|
||||
assert!(!request_id.is_empty());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_objects_before_sending() {
|
||||
let authz = RemoteAuthorization {
|
||||
client: client_with_handler(|_| -> http::Response<String> {
|
||||
panic!("invalid objects must not send a request")
|
||||
}),
|
||||
};
|
||||
let request = AccessControlEntryRequest::new(
|
||||
Object::Database(DatabaseObject::new("bad:database".into())),
|
||||
Subject::principal_api_key("secret").unwrap(),
|
||||
Privilege::Usage,
|
||||
);
|
||||
for result in [
|
||||
authz.add_acl(request.clone()).await,
|
||||
authz.delete_acl(request.clone()).await,
|
||||
authz
|
||||
.list_acls(ListAccessControlEntriesRequest {
|
||||
object: Some(request.object),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map(|_| ()),
|
||||
] {
|
||||
assert!(matches!(result, Err(crate::Error::InvalidInput { .. })));
|
||||
assert!(!result.unwrap_err().to_string().contains("secret"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_incompatible_subjects_before_sending() {
|
||||
let authz = RemoteAuthorization {
|
||||
client: client_with_handler(|_| -> http::Response<String> {
|
||||
panic!("invalid selectors must not send a request")
|
||||
}),
|
||||
};
|
||||
let request = RoleBindingRequest {
|
||||
subject: Subject::role("reader").unwrap(),
|
||||
role: "writer".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
authz.add_role_binding(request.clone()).await,
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
authz.delete_role_binding(request).await,
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
let request = ListRoleBindingsRequest {
|
||||
subject: Some(Subject::role("reader").unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
authz.list_role_bindings(request).await,
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
authz
|
||||
.get_principal(GetPrincipalRequest {
|
||||
principal: Subject::group_id("group").unwrap()
|
||||
})
|
||||
.await,
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
authz
|
||||
.get_group(GetGroupRequest {
|
||||
group: Subject::principal_api_key("secret").unwrap()
|
||||
})
|
||||
.await,
|
||||
Err(crate::Error::InvalidInput { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,10 @@ impl RemoteCatalog {
|
||||
|
||||
#[async_trait]
|
||||
impl Catalog for RemoteCatalog {
|
||||
fn authz(&self) -> Result<Arc<dyn crate::authz::Authorization>> {
|
||||
Ok(self.root.authz())
|
||||
}
|
||||
|
||||
fn uri(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
@@ -297,6 +297,12 @@ pub struct RemoteHostOverrides {
|
||||
}
|
||||
|
||||
impl RemoteDatabase {
|
||||
pub(crate) fn authz(&self) -> Arc<dyn crate::authz::Authorization> {
|
||||
Arc::new(super::authz::RemoteAuthorization {
|
||||
client: self.client.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn try_new(
|
||||
uri: &str,
|
||||
api_key: &str,
|
||||
|
||||
Reference in New Issue
Block a user