Skip to content
Open
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2118,6 +2118,34 @@ membership, this can be achieved with another query.
`<session>.permissions` was therefore removed in v2.0.0
in favor of `<session>.acls`.

Atomic ACLs
-----------

A list of permissions may be added to an object atomically using
the AccessManager's apply_atomic_operations method:
```
from irods.access import ACLOperation
from irods.helpers import home_collection
session = irods.helpers.make_session()
myCollection = session.collections.create(f"{home_collection(session).path}/newCollection")

session.acls.apply_atomic_operations(myCollection.path,
*[ACLOperation("read", "public"),
ACLOperation("write", "bob", "otherZone")
])
```
ACLOperation objects form a linear order with iRODSAccess objects, and
indeed are subclassed from them as well, allowing equivalency testing:

Thus, for example:
```
ACLOperation('read','public') in sess.acls.get(object)
```
is a valid operation. Consequently, any client application that habitually
caches object permissions could use similar code to check new ACLOperations against the cache
and conceivably be able to optimize size of an atomic ACLs request by eliminating
any ACLOperations that might have been redundant.

Quotas (v2.0.0)
---------------

Expand Down
99 changes: 82 additions & 17 deletions irods/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@
from irods.path import iRODSPath


_ichmod_listed_permissions = (
"own",
"delete_object",
"write",
"modify_object",
"create_object",
"delete_metadata",
"modify_metadata",
"create_metadata",
"read",
"read_object",
"read_metadata",
"null",
)


class _Access_LookupMeta(type):
def __getitem__(self, key):
return self.codes[key]
Expand All @@ -28,50 +44,35 @@
def to_string(cls, key):
return cls.strings[key]

codes = collections.OrderedDict(
(key_, value_)
for key_, value_ in sorted(
dict(
# copied from iRODS source code in
# ./server/core/include/irods/catalog_utilities.hpp:
null=1000,
execute=1010,
read_annotation=1020,
read_system_metadata=1030,
read_metadata=1040,
read_object=1050,
write_annotation=1060,
create_metadata=1070,
modify_metadata=1080,
delete_metadata=1090,
administer_object=1100,
create_object=1110,
modify_object=1120,
delete_object=1130,
create_token=1140,
delete_token=1150,
curate=1160,
own=1200,
).items(),
key=lambda _: _[1],
)
if key_
in (
# These are copied from ichmod help text.
"own",
"delete_object",
"write",
"modify_object",
"create_object",
"delete_metadata",
"modify_metadata",
"create_metadata",
"read",
"read_object",
"read_metadata",
"null",
)
if key_ in _ichmod_listed_permissions
)

Check failure on line 75 in irods/access.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff RUF012

RUF012: Mutable default value for class attribute [Ruff-specific rules:mutable-class-default]

strings = collections.OrderedDict((number, string) for string, number in codes.items())

Expand All @@ -91,6 +92,14 @@
self.user_zone = user_zone
self.user_type = user_type

def __lt__(self, other):

Check failure on line 95 in irods/access.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D105

D105: Missing docstring in magic method [pydocstyle:undocumented-magic-method]
return (self.access_name, self.user_name, self.user_zone, iRODSPath(self.path)) < (
other.access_name,
other.user_name,
other.user_zone,
iRODSPath(other.path),
)

def __eq__(self, other):
return (
self.access_name == other.access_name
Expand All @@ -102,8 +111,9 @@
def __hash__(self):
return hash((self.access_name, iRODSPath(self.path), self.user_name, self.user_zone))

def copy(self, decanonicalize=False):
def copy(self, decanonicalize=False, implied_zone=''):

Check failure on line 114 in irods/access.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D102

D102: Missing docstring in public method [pydocstyle:undocumented-public-method]
Copy link
Contributor

Choose a reason for hiding this comment

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

What is implied_zone?
Do we expect users of the PRC to ever use this parameter?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's useful for comparison purposes if you can tell __eq__ that a null length zone field implies the current zone name.

Copy link
Contributor

Choose a reason for hiding this comment

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

When would that case appear? Can you show an example?

Copy link
Collaborator Author

@d-w-moore d-w-moore Mar 25, 2026

Choose a reason for hiding this comment

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

Here's one:

>>> from irods.access import ACLOperation
>>> session = irods.helpers.make_session()
>>> coll = session.collections.get('/tempZone/home/rods')
>>>   
>>> # -- set a possible ACL operation for test/request
>>> aclo=ACLOperation('read_object', 'public')
>>> from pprint import pp
>>> # -- request current acls on the object
>>> got = session.acls.get(coll)
>>> pp(got)
[<iRODSAccess own /tempZone/home/rods rods(rodsadmin) tempZone>, 
 <iRODSAccess read_object /tempZone/home/rods public(rodsgroup) tempZone>]
>>>
# Failed attempt to see if acl already set (via direct equality test)
>>> aclo == got[1] 
False
>>>
>>> modified_acl = aclo.copy(implied_zone='tempZone')
>>> modified_got = got[1].copy(implied_zone='tempZone')
>>>
>>> # Equality test now works due to zone "normalization" 
>>> modified_acl == modified_got
True
>>> # therefore we'll also get:
>>> modified_acl in [acl.copy(implied_zone='tempZone') for acl in got]
True

Copy link
Contributor

Choose a reason for hiding this comment

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

Is calling <object>.copy(implied_zone=<zone>) required for equality testing?

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay. Letting it ride.

Resolving.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I could be over-engineering this...

Copy link
Contributor

Choose a reason for hiding this comment

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

It's fine. We discussed it and reached a decision.

We can always change things later if someone finds a good reason for us to do so.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ok. I realize it causes confusion or at least a double take .... If you think it might need a readme section, just say it and I can write up a short one with example.

Copy link
Contributor

Choose a reason for hiding this comment

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

A simple example showing the capability is all that's needed, I think.

other = copy.deepcopy(self)

if decanonicalize:
replacement_string = {
"read object": "read",
Expand All @@ -112,6 +122,11 @@
"modify_object": "write",
}.get(self.access_name)
other.access_name = replacement_string if replacement_string is not None else self.access_name

# Useful if we wish to force an explicitly specified local zone to an implicit zone spec in the copy, for equality testing:

Check failure on line 126 in irods/access.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff E501

E501: Line too long (131 > 120) [pycodestyle:line-too-long]
if '' != implied_zone == other.user_zone:
other.user_zone = ''

return other

def __repr__(self):
Expand All @@ -121,6 +136,56 @@
return f"<iRODSAccess {access_name} {self.path} {self.user_name}{user_type_hint} {self.user_zone}>"


class ACLOperation(iRODSAccess):

Check failure on line 139 in irods/access.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D101

D101: Missing docstring in public class [pydocstyle:undocumented-public-class]

Check failure on line 139 in irods/access.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff PLW1641

PLW1641: Object does not implement `__hash__` method [Pylint:eq-without-hash]
def __init__(self, access_name: str, user_name: str = "", user_zone: str = ""):

Check failure on line 140 in irods/access.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D107

D107: Missing docstring in `__init__` [pydocstyle:undocumented-public-init]
super().__init__(
access_name=access_name,
path="",
user_name=user_name,
user_zone=user_zone,
)

def __eq__(self, other):

Check failure on line 148 in irods/access.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D105

D105: Missing docstring in magic method [pydocstyle:undocumented-magic-method]
return (
self.access_name,
self.user_name,
self.user_zone,
) == (
other.access_name,
other.user_name,
other.user_zone,
)

def __lt__(self, other):

Check failure on line 159 in irods/access.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D105

D105: Missing docstring in magic method [pydocstyle:undocumented-magic-method]
return (
self.access_name,
self.user_name,
self.user_zone,
) < (
other.access_name,
other.user_name,
other.user_zone,
)

def __repr__(self):

Check failure on line 170 in irods/access.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D105

D105: Missing docstring in magic method [pydocstyle:undocumented-magic-method]
return f"<ACLOperation {self.access_name} {self.user_name} {self.user_zone}>"


(
_ichmod_synonym_mapping := {
# syn : canonical
"write": "modify_object",
"read": "read_object",
}
).update((key.replace("_", " "), key) for key in iRODSAccess.codes.keys())


all_permissions = {
**iRODSAccess.codes,
**{key: iRODSAccess.codes[_ichmod_synonym_mapping[key]] for key in _ichmod_synonym_mapping},
}


class _iRODSAccess_pre_4_3_0(iRODSAccess):
codes = collections.OrderedDict(
(key.replace("_", " "), value)
Expand Down
1 change: 1 addition & 0 deletions irods/api_number.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
"ATOMIC_APPLY_METADATA_OPERATIONS_APN": 20002,
"GET_FILE_DESCRIPTOR_INFO_APN": 20000,
"REPLICA_CLOSE_APN": 20004,
"ATOMIC_APPLY_ACL_OPERATIONS_APN": 20005,
"TOUCH_APN": 20007,
"AUTH_PLUG_REQ_AN": 1201,
"AUTHENTICATION_APN": 110000,
Expand Down
4 changes: 4 additions & 0 deletions irods/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,10 @@ class SYS_INVALID_INPUT_PARAM(SystemException):
code = -130000


class SYS_INTERNAL_ERR(SystemException):
code = -154000


class SYS_BAD_INPUT(iRODSException):
code = -158000

Expand Down
29 changes: 28 additions & 1 deletion irods/manager/access_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from irods.manager import Manager
from irods.api_number import api_number
from irods.message import ModAclRequest, iRODSMessage
from irods.message import ModAclRequest, iRODSMessage, JSON_Message
from irods.data_object import iRODSDataObject, irods_dirname, irods_basename
from irods.collection import iRODSCollection
from irods.models import (
Expand All @@ -14,6 +14,7 @@
CollectionAccess,
)
from irods.access import iRODSAccess
import irods.exception as ex
from irods.column import In
from irods.user import iRODSUser

Expand All @@ -36,6 +37,32 @@ def users_by_ids(session, ids=()):


class AccessManager(Manager):
@staticmethod
def _to_acl_operation_json(op_input: iRODSAccess):
return {
"acl": op_input.access_name,
"entity_name": op_input.user_name,
**({} if not (z := op_input.user_zone) else {"zone": z}),
}

def apply_atomic_operations(self, logical_path: str, *operations, admin=False):
request_text = {
"logical_path": logical_path,
"admin_mode": admin,
"operations": [self._to_acl_operation_json(op) for op in operations],
}

with self.sess.pool.get_connection() as conn:
request_msg = iRODSMessage(
"RODS_API_REQ",
JSON_Message(request_text, conn.server_version),
int_info=api_number["ATOMIC_APPLY_ACL_OPERATIONS_APN"],
)
conn.send(request_msg)
response = conn.recv()
response_msg = response.get_json_encoded_struct()
logger.debug("in atomic ACL api, server responded with: %r", response_msg)

def get(self, target, report_raw_acls=True, **kw):

if report_raw_acls:
Expand Down
41 changes: 40 additions & 1 deletion irods/test/access_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import sys
import unittest

from irods.access import iRODSAccess
from irods.access import iRODSAccess, ACLOperation
from irods.collection import iRODSCollection
from irods.column import In, Like
from irods.exception import UserDoesNotExist
Expand Down Expand Up @@ -497,6 +497,45 @@
self.sess,
)

def test_atomic_acls_505(self):
ses = self.sess
zone = user1 = user2 = user3 = group = None
try:
zone = ses.zones.create("twilight", "remote")
user1 = ses.users.create("test_user_505", "rodsuser")
user2 = ses.users.create("rod_serling_505#twilight", "rodsuser")
user3 = ses.users.create("local_test_user_505", "rodsuser")
group = ses.groups.create("test_group_505")
ses.acls.apply_atomic_operations(
self.coll_path,
a1:=ACLOperation("write", user1.name, user1.zone),
a2:=ACLOperation("read", user2.name, user2.zone),
a3:=ACLOperation("read", user3.name),
a4:=ACLOperation("read", group.name),

Check failure on line 514 in irods/test/access_test.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-format

Ruff format

Improper formatting
)

normalize = lambda access: access.copy(decanonicalize=True, implied_zone=ses.zone)

accesses = [normalize(acl) for acl in ses.acls.get(self.coll)]

# Assert that the ACLs we added are among those listed for the object in the catalog.
self.assertIn(normalize(a1), accesses)
self.assertIn(normalize(a2), accesses)
self.assertIn(normalize(a3), accesses)
self.assertIn(normalize(a4), accesses)

finally:
if user1:
user1.remove()
if user2:
user2.remove()
if user3:
user3.remove()
if group:
group.remove()
if zone:
zone.remove()


if __name__ == "__main__":
# let the tests find the parent irods lib
Expand Down
Loading