Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Fix rawAcl database scope #1514

Merged
merged 5 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ Changes are grouped as follows
- `Fixed` for any bug fixes.
- `Security` in case of vulnerabilities.

## [7.2.1] - 2023-11-17
### Fixed
- The new compare methods for capabilities in major version 7, `IAMAPI.verify_capabilities` and `IAMAPI.compare_capabilities`
now works correctly for rawAcl with database scope ("all tables").
### Removed
- Capability scopes no longer have the `is_within` method, and capabilities no longer have `has_capability`. Use the more
general `IAMAPI.compare_capabilities` instead.

## [7.2.0] - 2023-11-16
### Added
- The `trigger` method of the Workflow Execution API, now accepts a `client_credentials` to allow specifying specific
Expand Down Expand Up @@ -169,9 +177,9 @@ with no easy way to add a prefix. Also, it no longer expands metadata by default
## [6.39.6] - 2023-11-13
## Fixed
- HTTP status code retry strategy for RAW and labels. `/rows/insert` and `/rows/delete` will now
be retried for all status codes in `config.status_forcelist` (default 429, 502, 503, 504), while
`/dbs/{db}` and `/tables/{table}` will now only be retried for 429s and connection errors as those
endpoints are not idempotent.
be retried for all status codes in `config.status_forcelist` (default 429, 502, 503, 504), while
`/dbs/{db}` and `/tables/{table}` will now only be retried for 429s and connection errors as those
endpoints are not idempotent.
- Also, `labels/list` will now also be retried.

## [6.39.5] - 2023-11-12
Expand Down
33 changes: 23 additions & 10 deletions cognite/client/_api/iam.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
SessionList,
)
from cognite.client.data_classes.capabilities import (
AllScope,
Capability,
ProjectCapability,
ProjectCapabilityList,
RawAcl,
)
from cognite.client.data_classes.iam import TokenInspection
from cognite.client.utils._identifier import IdentifierSequence
Expand All @@ -46,8 +48,6 @@


def _convert_capability_to_tuples(capabilities: ComparableCapability, project: str | None = None) -> set[tuple]:
from cognite.client.data_classes import Group, GroupList

if isinstance(capabilities, ProjectCapability):
return ProjectCapabilityList([capabilities]).as_tuples(project)
if isinstance(capabilities, ProjectCapabilityList):
Expand All @@ -59,7 +59,6 @@ def _convert_capability_to_tuples(capabilities: ComparableCapability, project: s
capabilities = capabilities.capabilities or []
elif isinstance(capabilities, GroupList):
capabilities = [cap for grp in capabilities for cap in grp.capabilities or []]

if isinstance(capabilities, Sequence):
tpls: set[tuple] = set()
for cap in capabilities:
Expand Down Expand Up @@ -100,8 +99,10 @@ def compare_capabilities(
project (str | None): If a ProjectCapability or ProjectCapabilityList is passed, we need to know which CDF project
to pull capabilities from (existing might be from several). If project is not passed, and ProjectCapabilityList
is used, it will be inferred from the CogniteClient used to call retrieve it via token/inspect.
ignore_allscope_meaning (bool): Option on how to treat allScopes. When True, this function will return
e.g. an Acl scoped to a dataset even if the user have the same Acl scoped to all. Defaults to False.
ignore_allscope_meaning (bool): Option on how to treat scopes that encompass other scopes, like allScope. When True,
Copy link
Collaborator

Choose a reason for hiding this comment

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

When would you want this set to True?

Copy link
Contributor

Choose a reason for hiding this comment

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

That is the most logical to me. You have access to all data sets when you have all, which would be the most common use case.

Copy link
Contributor Author

@haakonvt haakonvt Nov 17, 2023

Choose a reason for hiding this comment

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

If you want two groups of capabilities to be equal, and they differ in scopes "not all" - but you still have some all-scopes here and there, then this option will tell you exactly what those differences are. For Peter's bootstrap CLI (and maybe others...) it means the manual work of removing any possible all-scopes or raw-database-scopes before comparing is not needed.

this function will return e.g. an Acl scoped to a dataset even if the user have the same Acl scoped to all. The
same logic applies to RawAcl scoped to a specific database->table, even when the user have access to all tables
in that database. Defaults to False.

Returns:
list[Capability]: A flattened list of the missing capabilities, meaning they each have exactly 1 action, 1 scope, 1 id etc.
Expand Down Expand Up @@ -147,18 +148,30 @@ def compare_capabilities(
to_check = _convert_capability_to_tuples(desired_capabilities, project)
missing = to_check - has_capabilties

if ignore_allscope_meaning:
return [Capability.from_tuple(tpl) for tpl in missing]

has_capabilties_lookup = {k: set(grp) for k, grp in groupby(sorted(has_capabilties), key=itemgetter(slice(2)))}
to_check_lookup = {k: set(grp) for k, grp in groupby(sorted(missing), key=itemgetter(slice(2)))}

missing.clear()
raw_group, raw_check_grp = set(), set()
for key, check_grp in to_check_lookup.items():
group = has_capabilties_lookup.get(key, set())
# If allScope exists for capability, we skip the missing:
if not any(grp[2] == "all" for grp in group):
if any(AllScope._scope_name == grp[2] for grp in group):
continue # If allScope exists for capability, we safely skip ahead
elif RawAcl._capability_name == next(iter(check_grp))[0]:
raw_group.update(group)
raw_check_grp.update(check_grp)
else:
missing.update(check_grp)

# Special handling of rawAcl which has a "hidden" database scope between "all" and "tables":
raw_to_check = {k: sorted(grp) for k, grp in groupby(sorted(raw_check_grp), key=itemgetter(slice(4)))}
raw_has_capabs = {k: sorted(grp) for k, grp in groupby(sorted(raw_group), key=itemgetter(slice(4)))}
for key, check_db_grp in raw_to_check.items():
if (db_group := raw_has_capabs.get(key)) and not db_group[0][-1]:
# [0] because empty string sorts first; [-1] is table; if no table -> db scope -> skip ahead
continue
missing.update(check_db_grp)

return [Capability.from_tuple(tpl) for tpl in missing]

def verify_capabilities(
Expand Down
2 changes: 1 addition & 1 deletion cognite/client/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from __future__ import annotations

__version__ = "7.2.0"
__version__ = "7.2.1"
__api_subversion__ = "V20220125"
67 changes: 5 additions & 62 deletions cognite/client/data_classes/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,6 @@ def as_tuples(self) -> set[tuple]:
# Basic implementation for all simple Scopes (e.g. all or currentuser)
return {(self._scope_name,)}

def is_within(self, other: Self) -> bool:
Copy link
Contributor

Choose a reason for hiding this comment

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

Surprised that these slipped through the reviews.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I actually kept it as a "simple 1:1 check", but decided now with the raw-database changes to not duplicate the error-prone scope logic.

raise NotImplementedError

@classmethod
def from_tuple(cls, tpl: tuple) -> Self:
acl_name, action, scope_name, *scope_params = tpl
Expand All @@ -113,9 +110,9 @@ def from_tuple(cls, tpl: tuple) -> Self:
scope = scope_cls(scope_params) # type: ignore [call-arg]
elif len(scope_params) == 2 and scope_cls is TableScope:
db, tbl = scope_params
scope = scope_cls({db: [tbl]}) # type: ignore [call-arg]
scope = scope_cls({db: [tbl] if tbl else []}) # type: ignore [call-arg]
else:
raise ValueError(f"tuple not understood ({tpl})")
raise ValueError(f"tuple not understood as capability: {tpl}")

return cast(Self, capability_cls(actions=[capability_cls.Action(action)], scope=scope))

Expand Down Expand Up @@ -162,13 +159,6 @@ def dump(self, camel_case: bool = True) -> dict[str, Any]:
capability_name = self._capability_name
return {to_camel_case(capability_name) if camel_case else to_snake_case(capability_name): data}

def has_capability(self, other: Capability) -> bool:
if not isinstance(self, type(other)):
return False
if not other.scope.is_within(self.scope):
return False
return not set(other.actions) - set(self.actions)

def as_tuples(self) -> set[tuple]:
return set(
(acl, action, *scope_tpl)
Expand Down Expand Up @@ -262,17 +252,11 @@ def as_tuples(self, project: str | None = None) -> set[tuple]:
class AllScope(Capability.Scope):
_scope_name = "all"

def is_within(self, other: Self) -> bool:
return isinstance(other, AllScope)


@dataclass(frozen=True)
class CurrentUserScope(Capability.Scope):
_scope_name = "currentuserscope"

def is_within(self, other: Self) -> bool:
return isinstance(other, (AllScope, CurrentUserScope))


@dataclass(frozen=True)
class IDScope(Capability.Scope):
Expand All @@ -285,9 +269,6 @@ def __post_init__(self) -> None:
def as_tuples(self) -> set[tuple]:
return {(self._scope_name, i) for i in self.ids}

def is_within(self, other: Self) -> bool:
return isinstance(other, AllScope) or type(self) is type(other) and set(self.ids).issubset(other.ids)


@dataclass(frozen=True)
class IDScopeLowerCase(Capability.Scope):
Expand All @@ -302,9 +283,6 @@ def __post_init__(self) -> None:
def as_tuples(self) -> set[tuple]:
return {(self._scope_name, i) for i in self.ids}

def is_within(self, other: Self) -> bool:
return isinstance(other, AllScope) or type(self) is type(other) and set(self.ids).issubset(other.ids)


@dataclass(frozen=True)
class ExtractionPipelineScope(Capability.Scope):
Expand All @@ -317,9 +295,6 @@ def __post_init__(self) -> None:
def as_tuples(self) -> set[tuple]:
return {(self._scope_name, i) for i in self.ids}

def is_within(self, other: Self) -> bool:
return isinstance(other, AllScope) or type(self) is type(other) and set(self.ids).issubset(other.ids)


@dataclass(frozen=True)
class DataSetScope(Capability.Scope):
Expand All @@ -332,9 +307,6 @@ def __post_init__(self) -> None:
def as_tuples(self) -> set[tuple]:
return {(self._scope_name, i) for i in self.ids}

def is_within(self, other: Self) -> bool:
return isinstance(other, AllScope) or type(self) is type(other) and set(self.ids).issubset(other.ids)


@dataclass(frozen=True)
class TableScope(Capability.Scope):
Expand All @@ -357,20 +329,9 @@ def dump(self, camel_case: bool = True) -> dict[str, Any]:
return {self._scope_name: {key: {k: {"tables": v} for k, v in self.dbs_to_tables.items()}}}

def as_tuples(self) -> set[tuple]:
return {(self._scope_name, db, tbl) for db, tables in self.dbs_to_tables.items() for tbl in tables}

def is_within(self, other: Self) -> bool:
if isinstance(other, AllScope):
return True
if not isinstance(other, TableScope):
return False

for db_name, tables in self.dbs_to_tables.items():
if (other_tables := other.dbs_to_tables.get(db_name)) is None:
return False
if not set(tables).issubset(other_tables):
return False
return True
# When the scope contains no tables, it means all tables... since database name must be at least 1
# character, we represent this internally with the empty string:
return {(self._scope_name, db, tbl) for db, tables in self.dbs_to_tables.items() for tbl in tables or [""]}


@dataclass(frozen=True)
Expand All @@ -384,9 +345,6 @@ def __post_init__(self) -> None:
def as_tuples(self) -> set[tuple]:
return {(self._scope_name, i) for i in self.root_ids}

def is_within(self, other: Self) -> bool:
return isinstance(other, AllScope) or type(self) is type(other) and set(self.root_ids).issubset(other.root_ids)


@dataclass(frozen=True)
class ExperimentsScope(Capability.Scope):
Expand All @@ -396,13 +354,6 @@ class ExperimentsScope(Capability.Scope):
def as_tuples(self) -> set[tuple]:
return {(self._scope_name, s) for s in self.experiments}

def is_within(self, other: Self) -> bool:
return (
isinstance(other, AllScope)
or type(self) is type(other)
and set(self.experiments).issubset(other.experiments)
)


@dataclass(frozen=True)
class SpaceIDScope(Capability.Scope):
Expand All @@ -412,11 +363,6 @@ class SpaceIDScope(Capability.Scope):
def as_tuples(self) -> set[tuple]:
return {(self._scope_name, s) for s in self.space_ids}

def is_within(self, other: Self) -> bool:
return (
isinstance(other, AllScope) or type(self) is type(other) and set(self.space_ids).issubset(other.space_ids)
)


@dataclass(frozen=True)
class UnknownScope(Capability.Scope):
Expand All @@ -434,9 +380,6 @@ def __getitem__(self, item: str) -> Any:
def as_tuples(self) -> set[tuple]:
raise NotImplementedError("Unknown scope cannot be converted to tuples (needed for comparisons)")

def is_within(self, other: Self) -> bool:
raise NotImplementedError("Unknown scope cannot be compared")


_SCOPE_CLASS_BY_NAME: MappingProxyType[str, type[Capability.Scope]] = MappingProxyType(
{c._scope_name: c for c in Capability.Scope.__subclasses__() if not issubclass(c, UnknownScope)}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[tool.poetry]
name = "cognite-sdk"

version = "7.2.0"
version = "7.2.1"
description = "Cognite Python SDK"
readme = "README.md"
documentation = "https://cognite-sdk-python.readthedocs-hosted.com"
Expand Down
37 changes: 37 additions & 0 deletions tests/tests_unit/test_data_classes/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ProjectCapabilityList,
ProjectsAcl,
RawAcl,
TableScope,
UnknownAcl,
UnknownScope,
)
Expand Down Expand Up @@ -301,6 +302,8 @@ def test_project_scope_is_all_projects(self, proj_cap_allprojects_dct):
loaded = ProjectCapabilityList.load([proj_cap_allprojects_dct])
assert type(loaded[0].project_scope) is AllProjectsScope


class TestIAMCompareCapabilities:
@pytest.mark.parametrize(
"capability",
[
Expand Down Expand Up @@ -347,6 +350,40 @@ def test_partly_missing_capabilities(
)
assert missing_acls == [has_not]

def test_raw_acl_database_scope_only(self, cognite_client):
# Would fail with: 'ValueError: No capabilities given' prior to 7.2.1 due to a bug in 'as_tuples'.
has_all_scope = RawAcl([RawAcl.Action.Read], AllScope)
has_db_scope = RawAcl([RawAcl.Action.Read], TableScope(dbs_to_tables={"db1": []}))
assert not cognite_client.iam.compare_capabilities(has_all_scope, has_db_scope)

@pytest.mark.parametrize(
"extra_existing",
[
[],
[RawAcl(actions=[RawAcl.Action.Read], scope=AllScope)],
[RawAcl(actions=[RawAcl.Action.Read], scope=TableScope({"db1": []}))],
[RawAcl(actions=[RawAcl.Action.Read], scope=TableScope({"db1": {"tables": []}}))],
Comment on lines +364 to +365
Copy link
Contributor

Choose a reason for hiding this comment

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

These are confusing, but if I understand it correctly, it means all in db1? Both of them are equal?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This means access is granted to all current and all future tables in the database "db1":
TableScope({"db1": []})

I don't like the syntax one bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

However, that's how the API does it, and hence we shouldn't add our own DataBaseScope imo

],
)
def test_raw_acl_database_scope(self, cognite_client, extra_existing):
existing = [
RawAcl([RawAcl.Action.Read], RawAcl.Scope.Table({"db1": ["t1"]})),
RawAcl([RawAcl.Action.Read], RawAcl.Scope.Table({"db1": ["t1", "t2"]})),
RawAcl([RawAcl.Action.Read], RawAcl.Scope.Table({"db2": ["t1", "t2"]})),
*extra_existing,
]
desired = [
RawAcl([RawAcl.Action.Read], RawAcl.Scope.Table({"db1": ["t1", "t2", "t3"]})),
RawAcl([RawAcl.Action.Write], RawAcl.Scope.Table({"db1": ["t1"]})),
]
missing = cognite_client.iam.compare_capabilities(existing, desired)
if extra_existing:
assert missing == [RawAcl([RawAcl.Action.Write], RawAcl.Scope.Table({"db1": ["t1"]}))]
else:
assert len(missing) == 2
assert RawAcl([RawAcl.Action.Read], RawAcl.Scope.Table({"db1": ["t3"]})) in missing
assert RawAcl([RawAcl.Action.Write], RawAcl.Scope.Table({"db1": ["t1"]})) in missing


@pytest.mark.parametrize(
"dct",
Expand Down