Skip to content

Commit 4873e62

Browse files
committed
Permission bug fixes for NSF folders and record sharing
1 parent b8df5a2 commit 4873e62

5 files changed

Lines changed: 702 additions & 84 deletions

File tree

keepercli-package/src/keepercli/commands/nsf_commands.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,7 +1279,7 @@ def execute(self, context: KeeperParams, **kwargs):
12791279
vault, kwargs.get('folder'), action=action, role=role,
12801280
recursive=kwargs.get('recursive', False), current_user=login))
12811281

1282-
if not plan.updates and not plan.creates and not plan.revokes:
1282+
if not plan.updates and not plan.creates and not plan.revokes and not plan.denies:
12831283
if plan.skipped:
12841284
logger.warning('No permission changes can be made (see skipped entries).')
12851285
else:
@@ -1309,7 +1309,7 @@ def execute(self, context: KeeperParams, **kwargs):
13091309
def _print_plan(plan) -> None:
13101310
for label, items in (
13111311
('SKIP', plan.skipped), ('GRANT/UPDATE', plan.updates + plan.creates),
1312-
('REVOKE', plan.revokes)):
1312+
('REVOKE', plan.revokes), ('DENY INHERITED', plan.denies)):
13131313
if not items:
13141314
continue
13151315
logger.info(f'\n{label}:')

keepersdk-package/src/keepersdk/vault/nsf_common.py

Lines changed: 293 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from __future__ import annotations
44

5-
from typing import Any, Dict, Optional, Tuple
5+
import json
6+
from typing import Any, Dict, List, Optional, Tuple
67

78
from .. import crypto, utils
89
from ..proto import folder_pb2, record_pb2, record_sharing_pb2
@@ -101,18 +102,21 @@ def encrypt_for_team(
101102
plaintext_key: bytes,
102103
team_keys,
103104
*,
105+
prefer_aes: bool = False,
104106
forbid_rsa: bool = False) -> Tuple[bytes, int]:
105107
aes = getattr(team_keys, 'aes', None)
106108
ec_bytes = getattr(team_keys, 'ec', None)
107109
rsa_bytes = getattr(team_keys, 'rsa', None)
110+
if prefer_aes and aes:
111+
if forbid_rsa:
112+
return crypto.encrypt_aes_v2(plaintext_key, aes), folder_pb2.encrypted_by_data_key_gcm
113+
return crypto.encrypt_aes_v1(plaintext_key, aes), folder_pb2.encrypted_by_data_key
108114
if rsa_bytes and not forbid_rsa:
109115
rsa_key = crypto.load_rsa_public_key(rsa_bytes)
110116
return crypto.encrypt_rsa(plaintext_key, rsa_key), folder_pb2.encrypted_by_public_key
111117
if ec_bytes:
112118
ec_key = crypto.load_ec_public_key(ec_bytes)
113119
return crypto.encrypt_ec(plaintext_key, ec_key), folder_pb2.encrypted_by_public_key_ecc
114-
if aes:
115-
return crypto.encrypt_aes_v2(plaintext_key, aes), folder_pb2.encrypted_by_data_key_gcm
116120
raise ValueError('No public key found for team')
117121

118122

@@ -320,6 +324,292 @@ def folder_access_role_label(
320324
return 'unknown'
321325

322326

327+
_PERMISSION_CAMEL_KEYS: Dict[str, str] = {
328+
'can_update_access': 'canUpdateAccess',
329+
'can_update_setting': 'canUpdateSetting',
330+
'can_delete': 'canDelete',
331+
'can_change_ownership': 'canChangeOwnership',
332+
'can_edit': 'canEdit',
333+
'can_view': 'canView',
334+
'can_list_access': 'canListAccess',
335+
}
336+
337+
338+
def _current_user_account_uid_b64(vault: VaultOnline) -> str:
339+
account_uid = vault.keeper_auth.auth_context.account_uid
340+
return utils.base64_url_encode(account_uid) if account_uid else ''
341+
342+
343+
def _parse_permissions_blob(raw: Any) -> Dict[str, Any]:
344+
if isinstance(raw, dict):
345+
return raw
346+
if isinstance(raw, str) and raw:
347+
try:
348+
parsed = json.loads(raw)
349+
return parsed if isinstance(parsed, dict) else {}
350+
except (TypeError, ValueError):
351+
return {}
352+
return {}
353+
354+
355+
def _permission_value(perms: Dict[str, Any], key: str) -> bool:
356+
if not perms:
357+
return False
358+
if key in perms:
359+
return bool(perms[key])
360+
camel = _PERMISSION_CAMEL_KEYS.get(key)
361+
if camel and camel in perms:
362+
return bool(perms[camel])
363+
return False
364+
365+
366+
def _access_type_is_owner(access_type: Any) -> bool:
367+
if access_type == folder_pb2.AT_OWNER:
368+
return True
369+
return isinstance(access_type, str) and access_type == 'AT_OWNER'
370+
371+
372+
def is_current_user_nsf_accessor(
373+
accessor: Dict[str, Any],
374+
vault: VaultOnline,
375+
account_uid_b64: str) -> bool:
376+
"""Return True when *accessor* belongs to the logged-in user."""
377+
username = vault.keeper_auth.auth_context.username
378+
accessor_username = accessor.get('username') or accessor.get('accessor_name')
379+
if accessor_username and username:
380+
return accessor_username.casefold() == username.casefold()
381+
accessor_uid = accessor.get('access_type_uid') or accessor.get('accessor_uid')
382+
return bool(accessor_uid and account_uid_b64 and accessor_uid == account_uid_b64)
383+
384+
385+
def _folder_owner_info(vault: VaultOnline, folder_uid: str) -> Tuple[Optional[str], Optional[str]]:
386+
view = vault.nsf_data
387+
if view is None:
388+
return None, None
389+
row = view.storage.folders.get_entity(folder_uid)
390+
if row is None:
391+
return None, None
392+
return row.owner_username or None, row.owner_account_uid or None
393+
394+
395+
def is_nsf_folder_owner_user(vault: VaultOnline, folder_uid: str) -> bool:
396+
"""Return True when the logged-in user owns *folder_uid*."""
397+
account_uid_b64 = _current_user_account_uid_b64(vault)
398+
username = vault.keeper_auth.auth_context.username
399+
owner_username, owner_account_uid = _folder_owner_info(vault, folder_uid)
400+
if owner_account_uid and account_uid_b64 and owner_account_uid == account_uid_b64:
401+
return True
402+
if owner_username and username and owner_username.casefold() == username.casefold():
403+
return True
404+
return False
405+
406+
407+
def _folder_accessor_from_storage(fa: Any) -> Dict[str, Any]:
408+
return {
409+
'access_type_uid': fa.access_type_uid,
410+
'access_type': fa.access_type,
411+
'permissions': _parse_permissions_blob(fa.permissions_json),
412+
}
413+
414+
415+
def collect_nsf_folder_accessors(vault: VaultOnline, folder_uid: str) -> List[Dict[str, Any]]:
416+
"""Folder accessor rows from sync cache, falling back to the access API."""
417+
accessors: List[Dict[str, Any]] = []
418+
view = vault.nsf_data
419+
if view is not None:
420+
for fa in view.storage.folder_accesses.get_links_by_subject(folder_uid):
421+
accessors.append(_folder_accessor_from_storage(fa))
422+
if accessors:
423+
return accessors
424+
from .nsf_management import get_nsf_folder_access
425+
try:
426+
info = get_nsf_folder_access(vault, [folder_uid])
427+
for result in info.get('results', []):
428+
if result.get('success'):
429+
accessors.extend(result.get('accessors', []))
430+
except Exception:
431+
pass
432+
return accessors
433+
434+
435+
def _record_accessor_from_storage(ra: Any) -> Dict[str, Any]:
436+
return {
437+
'access_type_uid': ra.access_type_uid,
438+
'owner': ra.owner,
439+
'inherited': ra.inherited,
440+
'denied_access': ra.denied_access,
441+
'can_update_access': ra.can_update_access,
442+
'can_change_ownership': ra.can_change_ownership,
443+
'can_delete': ra.can_delete,
444+
'can_edit': ra.can_edit,
445+
}
446+
447+
448+
def find_record_user_accesses(
449+
vault: VaultOnline,
450+
record_uid: str,
451+
recipient_email: str) -> List[Dict[str, Any]]:
452+
"""Return non-owner AT_USER accessor rows for *recipient_email* on *record_uid*."""
453+
email_cf = recipient_email.casefold()
454+
matches: List[Dict[str, Any]] = []
455+
for accessor in collect_nsf_record_accessors(vault, record_uid):
456+
if accessor.get('owner'):
457+
continue
458+
access_type = accessor.get('access_type') or 'AT_USER'
459+
if access_type not in ('AT_USER', ''):
460+
continue
461+
accessor_name = accessor.get('accessor_name') or accessor.get('username') or ''
462+
if accessor_name.casefold() != email_cf:
463+
continue
464+
matches.append(accessor)
465+
return matches
466+
467+
468+
def record_user_has_direct_access(accesses: List[Dict[str, Any]]) -> bool:
469+
return any(not accessor.get('inherited') for accessor in accesses)
470+
471+
472+
def record_user_has_inherited_access(accesses: List[Dict[str, Any]]) -> bool:
473+
return any(accessor.get('inherited') for accessor in accesses)
474+
475+
476+
def collect_nsf_record_accessors(vault: VaultOnline, record_uid: str) -> List[Dict[str, Any]]:
477+
"""Record accessor rows from sync cache, falling back to the access API."""
478+
accessors: List[Dict[str, Any]] = []
479+
view = vault.nsf_data
480+
if view is not None:
481+
for ra in view.storage.record_accesses.get_links_by_subject(record_uid):
482+
accessors.append(_record_accessor_from_storage(ra))
483+
if accessors:
484+
return accessors
485+
from .nsf_management import get_nsf_record_accesses
486+
try:
487+
info = get_nsf_record_accesses(vault, [record_uid])
488+
accessors.extend(info.get('record_accesses', []))
489+
except Exception:
490+
pass
491+
return accessors
492+
493+
494+
def _record_permission_value(accessor: Dict[str, Any], key: str) -> bool:
495+
if key in accessor:
496+
return bool(accessor[key])
497+
return _permission_value(accessor.get('permissions') or {}, key)
498+
499+
500+
def require_nsf_folder_permission(
501+
vault: VaultOnline,
502+
folder_uid: str,
503+
permission_key: str,
504+
error_message: str) -> None:
505+
"""Raise ValueError when the current user lacks *permission_key* on a folder."""
506+
if is_nsf_folder_owner_user(vault, folder_uid):
507+
return
508+
509+
accessors = collect_nsf_folder_accessors(vault, folder_uid)
510+
if not accessors:
511+
return
512+
513+
account_uid_b64 = _current_user_account_uid_b64(vault)
514+
owner_username, owner_account_uid = _folder_owner_info(vault, folder_uid)
515+
for accessor in accessors:
516+
if not is_current_user_nsf_accessor(accessor, vault, account_uid_b64):
517+
continue
518+
if (accessor.get('owner')
519+
or _access_type_is_owner(accessor.get('access_type'))
520+
or is_nsf_folder_owner(accessor, owner_username, owner_account_uid)):
521+
return
522+
perms = accessor.get('permissions') or {}
523+
if _permission_value(perms, permission_key):
524+
return
525+
raise ValueError(error_message)
526+
527+
raise ValueError(error_message)
528+
529+
530+
def require_nsf_folder_share_permission(vault: VaultOnline, folder_uid: str) -> None:
531+
"""Raise ValueError when the current user cannot share or manage folder access."""
532+
require_nsf_folder_permission(
533+
vault,
534+
folder_uid,
535+
'can_update_access',
536+
'You do not have permission to share this folder.')
537+
538+
539+
def require_nsf_record_permission(
540+
vault: VaultOnline,
541+
record_uid: str,
542+
permission_key: str,
543+
error_message: str) -> None:
544+
"""Raise ValueError when the current user lacks *permission_key* on a record."""
545+
accessors = collect_nsf_record_accessors(vault, record_uid)
546+
if not accessors:
547+
return
548+
549+
account_uid_b64 = _current_user_account_uid_b64(vault)
550+
for accessor in accessors:
551+
if not is_current_user_nsf_accessor(accessor, vault, account_uid_b64):
552+
continue
553+
if accessor.get('owner'):
554+
return
555+
if _record_permission_value(accessor, permission_key):
556+
return
557+
raise ValueError(error_message)
558+
559+
raise ValueError(error_message)
560+
561+
562+
def require_nsf_record_share_permission(vault: VaultOnline, record_uid: str) -> None:
563+
"""Raise ValueError when the current user cannot share or manage record access."""
564+
require_nsf_record_permission(
565+
vault,
566+
record_uid,
567+
'can_update_access',
568+
'You do not have permission to share this record.')
569+
570+
571+
def require_nsf_record_ownership_permission(vault: VaultOnline, record_uid: str) -> None:
572+
"""Raise ValueError when the current user cannot transfer record ownership."""
573+
require_nsf_record_permission(
574+
vault,
575+
record_uid,
576+
'can_change_ownership',
577+
'You do not have permission to transfer ownership of this record.')
578+
579+
580+
def folder_inherits_parent_permissions(vault: VaultOnline, folder_uid: str) -> bool:
581+
"""Return True when *folder_uid* has a parent and still inherits its access list."""
582+
from .nsf_management import ROOT_FOLDER_UID
583+
584+
view = vault.nsf_data
585+
if view is None:
586+
return False
587+
node = view.get_folder(folder_uid)
588+
parent_uid = node.parent_uid if node else None
589+
if not parent_uid:
590+
row = view.storage.folders.get_entity(folder_uid)
591+
parent_uid = row.parent_uid if row else None
592+
if not parent_uid or parent_uid == ROOT_FOLDER_UID:
593+
return False
594+
row = view.storage.folders.get_entity(folder_uid)
595+
if row is None:
596+
return True
597+
return row.inherit_user_permissions != int(folder_pb2.BOOLEAN_FALSE)
598+
599+
600+
def ensure_folder_direct_permissions(
601+
vault: VaultOnline,
602+
folder_uid: str,
603+
*,
604+
request_sync: bool = False) -> bool:
605+
"""Disable parent permission inheritance so folder access changes apply locally."""
606+
if not folder_inherits_parent_permissions(vault, folder_uid):
607+
return False
608+
from .nsf_management import update_nsf_folder
609+
update_nsf_folder(vault, folder_uid, inherit_permissions=False, request_sync=request_sync)
610+
return True
611+
612+
323613
def access_role_label(access: Dict[str, Any]) -> str:
324614
if access.get('owner'):
325615
return 'owner'

keepersdk-package/src/keepersdk/vault/nsf_management.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -786,8 +786,11 @@ def get_nsf_record_accesses(
786786
}
787787
for flag in ('can_view_title', 'can_edit', 'can_view', 'can_list_access',
788788
'can_update_access', 'can_delete', 'can_change_ownership',
789-
'can_request_access', 'can_approve_access'):
790-
ao[flag] = getattr(d, flag, False)
789+
'can_request_access', 'can_approve_access', 'denied_access'):
790+
if flag == 'denied_access':
791+
ao[flag] = getattr(d, 'deniedAccess', False)
792+
else:
793+
ao[flag] = getattr(d, flag, False)
791794
result['record_accesses'].append(ao)
792795
for fu in rs.forbiddenRecords:
793796
result['forbidden_records'].append(utils.base64_url_encode(fu))

0 commit comments

Comments
 (0)