atraxis 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. atraxis/__init__.py +1 -0
  2. atraxis/__main__.py +3 -0
  3. atraxis/account_admin.py +140 -0
  4. atraxis/account_admin_commands.py +56 -0
  5. atraxis/account_backup.py +128 -0
  6. atraxis/account_commands.py +98 -0
  7. atraxis/account_delegation.py +182 -0
  8. atraxis/account_entities.py +223 -0
  9. atraxis/account_forms.py +23 -0
  10. atraxis/account_json.py +88 -0
  11. atraxis/account_lock.py +24 -0
  12. atraxis/account_schema.py +56 -0
  13. atraxis/account_search.py +68 -0
  14. atraxis/account_search_textual.py +86 -0
  15. atraxis/account_sqlite.py +220 -0
  16. atraxis/account_storage.py +78 -0
  17. atraxis/account_store.py +130 -0
  18. atraxis/account_textual.py +114 -0
  19. atraxis/accounts.py +147 -0
  20. atraxis/administration_cli.py +43 -0
  21. atraxis/cli.py +151 -0
  22. atraxis/client.py +231 -0
  23. atraxis/client_config.py +36 -0
  24. atraxis/collection.py +77 -0
  25. atraxis/config.py +194 -0
  26. atraxis/configuration_admin.py +208 -0
  27. atraxis/configuration_commands.py +43 -0
  28. atraxis/doctor.py +199 -0
  29. atraxis/installation/__init__.py +1 -0
  30. atraxis/installation/deployment.py +208 -0
  31. atraxis/installation/engine.py +86 -0
  32. atraxis/installation/filesystem.py +124 -0
  33. atraxis/installation/host.py +60 -0
  34. atraxis/installation/material.py +26 -0
  35. atraxis/installation/probe.py +128 -0
  36. atraxis/installation/tls.py +50 -0
  37. atraxis/installation/validation.py +224 -0
  38. atraxis/installation/wizard.py +178 -0
  39. atraxis/installation/worker.py +33 -0
  40. atraxis/intervention_search.py +112 -0
  41. atraxis/intervention_search_textual.py +58 -0
  42. atraxis/inventory_access.py +164 -0
  43. atraxis/inventory_admin.py +165 -0
  44. atraxis/inventory_commands.py +41 -0
  45. atraxis/inventory_search.py +119 -0
  46. atraxis/inventory_textual.py +122 -0
  47. atraxis/models.py +85 -0
  48. atraxis/observation_codec.py +184 -0
  49. atraxis/organization_context.py +36 -0
  50. atraxis/parsing.py +264 -0
  51. atraxis/prometheus.py +140 -0
  52. atraxis/prometheus_certlord.py +21 -0
  53. atraxis/prometheus_monit.py +21 -0
  54. atraxis/prometheus_observation.py +254 -0
  55. atraxis/read_request.py +22 -0
  56. atraxis/resource_scopes.py +130 -0
  57. atraxis/retention.py +59 -0
  58. atraxis/rule_commands.py +247 -0
  59. atraxis/rule_files.py +18 -0
  60. atraxis/rule_inputs.py +26 -0
  61. atraxis/rule_observation_config.py +37 -0
  62. atraxis/rule_observation_dispatch.py +34 -0
  63. atraxis/rule_observations.py +134 -0
  64. atraxis/rule_operator.py +160 -0
  65. atraxis/rule_protocol.py +3 -0
  66. atraxis/rule_remote.py +23 -0
  67. atraxis/rule_remote_worker.py +5 -0
  68. atraxis/rule_server.py +162 -0
  69. atraxis/rule_textual.py +305 -0
  70. atraxis/rule_tui.py +153 -0
  71. atraxis/rule_workbench.py +283 -0
  72. atraxis/rules.py +2 -0
  73. atraxis/search_client.py +66 -0
  74. atraxis/search_commands.py +55 -0
  75. atraxis/search_textual.py +107 -0
  76. atraxis/server.py +331 -0
  77. atraxis/service.py +101 -0
  78. atraxis/setup.py +117 -0
  79. atraxis/source_adapters/__init__.py +20 -0
  80. atraxis/source_adapters/certlord.py +16 -0
  81. atraxis/source_adapters/contracts.py +40 -0
  82. atraxis/source_adapters/monit_docker.py +16 -0
  83. atraxis/source_adapters/prometheus.py +24 -0
  84. atraxis/source_adapters/prometheus_certlord.py +41 -0
  85. atraxis/source_adapters/prometheus_monit.py +42 -0
  86. atraxis/source_config.py +181 -0
  87. atraxis/transport.py +119 -0
  88. atraxis/transport_worker.py +143 -0
  89. atraxis/web.py +166 -0
  90. atraxis/web_account_search.py +23 -0
  91. atraxis/web_accounts.py +134 -0
  92. atraxis/web_configuration.py +115 -0
  93. atraxis/web_dispatch.py +171 -0
  94. atraxis/web_intervention_detail.py +180 -0
  95. atraxis/web_intervention_search.py +59 -0
  96. atraxis/web_inventory_admin.py +108 -0
  97. atraxis/web_inventory_search.py +65 -0
  98. atraxis/web_observations.py +100 -0
  99. atraxis/web_organization_context.py +94 -0
  100. atraxis/web_reconciliation.py +146 -0
  101. atraxis/web_rule_activation.py +195 -0
  102. atraxis/web_rule_audit.py +177 -0
  103. atraxis/web_rule_creation.py +142 -0
  104. atraxis/web_rule_editor.py +291 -0
  105. atraxis/web_rule_retirement.py +88 -0
  106. atraxis/web_rules.py +225 -0
  107. atraxis/web_simulation.py +191 -0
  108. atraxis-1.0.2.dist-info/METADATA +507 -0
  109. atraxis-1.0.2.dist-info/RECORD +113 -0
  110. atraxis-1.0.2.dist-info/WHEEL +5 -0
  111. atraxis-1.0.2.dist-info/entry_points.txt +2 -0
  112. atraxis-1.0.2.dist-info/licenses/LICENSE +232 -0
  113. atraxis-1.0.2.dist-info/top_level.txt +1 -0
atraxis/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Private Atraxis prototype: read-only observations."""
atraxis/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from atraxis.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,140 @@
1
+ """Trusted identity mapping and shared administration service for web/CLI/Textual."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import uuid
7
+
8
+ import yaml
9
+
10
+ from atraxis.accounts import AccountError, Directory, identifier
11
+ from atraxis.account_store import AccountStore
12
+ from atraxis.account_storage import storage_config
13
+ from atraxis.account_delegation import checked_request, propose, view
14
+ from atraxis.config import _StrictLoader
15
+ from atraxis.rule_files import read_private
16
+
17
+ MAX_PROFILE_BYTES = 65536
18
+
19
+
20
+ def load_admin_profile(path):
21
+ try:
22
+ raw = read_private(path,MAX_PROFILE_BYTES)
23
+ data = yaml.load(raw,Loader=_StrictLoader)
24
+ if (type(data) is not dict or set(data) != {'version','store','identities'}
25
+ or type(data['version']) is not int or data['version'] != 1
26
+ or type(data['identities']) is not list
27
+ or not 1 <= len(data['identities']) <= 128):
28
+ raise ValueError()
29
+ seen=set()
30
+ for entry in data['identities']:
31
+ if type(entry) is not dict or set(entry) not in ({'username','subject'},{'uid','subject'}) or not identifier(entry['subject']):
32
+ raise ValueError()
33
+ key = 'username' if 'username' in entry else 'uid'
34
+ value=entry[key]
35
+ if (key=='username' and not identifier(value) or key=='uid' and (type(value) is not int or value<0)
36
+ or (key,value) in seen):
37
+ raise ValueError()
38
+ seen.add((key,value))
39
+ destination=storage_config(data['store'], Path(path).absolute().parent)
40
+ return data['identities'],destination,hashlib.sha256(raw).hexdigest()
41
+ except (OSError,ValueError,TypeError,KeyError,RecursionError,yaml.YAMLError):
42
+ raise AccountError('invalid_account_admin_profile') from None
43
+
44
+
45
+ class AccountAdmin:
46
+ def __init__(self,path,username=None):
47
+ self.path=Path(path).absolute()
48
+ self.web=username is not None
49
+ self.username=username if self.web else 'uid:'+str(os.getuid())
50
+ self.initial=self.entry()
51
+
52
+ def entry(self):
53
+ entries,path,digest=load_admin_profile(self.path)
54
+ key,value=('username',self.username) if self.web else ('uid',os.getuid())
55
+ matches=[entry for entry in entries if entry.get(key)==value]
56
+ if len(matches)!=1:raise PermissionError('account_admin_not_mapped')
57
+ result={'subject':matches[0]['subject'],'store':path,'profile':digest}
58
+ if hasattr(self,'initial') and result!=self.initial:
59
+ raise PermissionError('account_admin_identity_changed')
60
+ return result
61
+
62
+ def inspect(self):
63
+ entry=self.entry();store=AccountStore(entry['store']);snapshot=store.snapshot(audit=False)
64
+ result={'revision':snapshot['revision'],'subject':entry['subject'],
65
+ 'organizations':view(Directory(snapshot['directory']),entry['subject'])}
66
+ from atraxis.account_entities import view as entity_view
67
+ result.update(entity_view(Directory(snapshot['directory']), entry['subject']))
68
+ if store.revision()!=snapshot['revision']:raise PermissionError('account_administration_changed')
69
+ self.entry()
70
+ if not result['organizations'] and not result['entity_grants']:raise PermissionError('account_administration_denied')
71
+ return result
72
+
73
+ def search(self,query):
74
+ entry = self.entry()
75
+ store = AccountStore(entry['store'])
76
+ result = store.search(entry['subject'],query)
77
+ self.entry()
78
+ if store.revision() != result['revision']:
79
+ raise PermissionError('account_search_access_changed')
80
+ self.entry()
81
+ return result
82
+
83
+ def prepare(self,request,expected_revision):
84
+ entry=self.entry();snapshot=AccountStore(entry['store']).snapshot(audit=False)
85
+ if snapshot['revision']!=expected_revision:raise AccountError('account_revision_conflict')
86
+ request=checked_request(request);created_id=uuid.uuid4().hex
87
+ _,audit=propose(Directory(snapshot['directory']),entry['subject'],request,new_id=created_id)
88
+ self.entry()
89
+ return {'request':request,'revision':snapshot['revision'],'identity':entry,'created_id':created_id,'audit':audit}
90
+
91
+ def commit(self,plan):
92
+ entry=self.entry()
93
+ if plan['identity']!=entry:raise PermissionError('account_admin_identity_changed')
94
+ store=AccountStore(entry['store']);snapshot=store.snapshot(audit=False)
95
+ if snapshot['revision']!=plan['revision']:raise AccountError('account_revision_conflict')
96
+ candidate,audit=propose(Directory(snapshot['directory']),entry['subject'],plan['request'],new_id=plan['created_id'])
97
+ def authorize(current,proposed):
98
+ self.entry() # Current identity and delegation under the snapshot writer lock.
99
+ checked,metadata=propose(current,entry['subject'],plan['request'],new_id=plan['created_id'])
100
+ if checked.document()!=proposed.document() or metadata!=audit:
101
+ raise PermissionError('account_administration_changed')
102
+ return store.apply(candidate,plan['revision'],authorize=authorize,actor=audit)
103
+
104
+
105
+ def request_from_fields(fields):
106
+ """Finite form contract shared by web and terminal; no identity/grant overrides."""
107
+ operation=fields.get('operation')
108
+ from atraxis.account_entities import OPERATIONS as ENTITY_OPERATIONS, DELEGATE_OPERATIONS, checked_request as entity_request
109
+ from atraxis.account_forms import FIELDS
110
+ if operation in ENTITY_OPERATIONS | DELEGATE_OPERATIONS:
111
+ if set(fields) != {'operation'} | {field[0] for field in FIELDS[operation]}:
112
+ raise AccountError('invalid_account_form')
113
+ values = {key:value for key,value in fields.items() if key!='operation'}
114
+ for key in ('enabled','inherit_parent','delegable'):
115
+ if key in values:
116
+ if values[key] not in ('yes','no'): raise AccountError('invalid_account_form')
117
+ values[key] = values[key]=='yes'
118
+ if operation in ENTITY_OPERATIONS:
119
+ if 'parent' in values: values['parent'] = values['parent'] or None
120
+ return entity_request(dict(operation=operation,entity=values))
121
+ if operation == 'entity_delegation.create':
122
+ values['scope'] = dict(kind=values.pop('scope_kind'),id=values.pop('scope_id') or None)
123
+ values['operations'] = [value.strip() for value in values['operations'].split(',') if value.strip()]
124
+ return entity_request(dict(values, operation=operation))
125
+ if operation in ('membership.add','membership.remove'):
126
+ if set(fields)!={'operation','organization','subject','group'}:raise AccountError('invalid_account_form')
127
+ return checked_request(dict(operation=operation,organization=fields['organization'],subject=fields['subject'],group=fields['group'] or None))
128
+ if operation=='delegation.revoke':
129
+ if set(fields)!={'operation','grant'}:raise AccountError('invalid_account_form')
130
+ return checked_request(fields)
131
+ if operation=='delegation.create':
132
+ if set(fields)!={'operation','parent','subject','groups','operations','organization_membership','delegable'}:
133
+ raise AccountError('invalid_account_form')
134
+ if fields['organization_membership'] not in ('yes','no') or fields['delegable'] not in ('yes','no'):
135
+ raise AccountError('invalid_account_form')
136
+ return checked_request(dict(operation=operation,parent=fields['parent'],subject=fields['subject'],
137
+ groups=[g.strip() for g in fields['groups'].split(',') if g.strip()],
138
+ operations=[o.strip() for o in fields['operations'].split(',') if o.strip()],
139
+ organization_membership=fields['organization_membership']=='yes',delegable=fields['delegable']=='yes'))
140
+ raise AccountError('invalid_account_form')
@@ -0,0 +1,56 @@
1
+ """Scoped administration commands. The OS mapping supplies the acting subject."""
2
+ import json
3
+ import sys
4
+ import yaml
5
+ from atraxis.account_admin import AccountAdmin
6
+ from atraxis.account_delegation import checked_request
7
+ from atraxis.config import _StrictLoader
8
+ from atraxis.rule_files import read_private
9
+
10
+ MAX_REQUEST_BYTES = 32768
11
+
12
+
13
+ def add_parser(commands):
14
+ parser=commands.add_parser('accounts-admin',help='scoped account administration')
15
+ parser.add_argument('--config',required=True)
16
+ actions=parser.add_subparsers(dest='admin_command',required=True)
17
+ actions.add_parser('show');actions.add_parser('tui')
18
+ search=actions.add_parser('search',help='search the complete authorized account scope')
19
+ search.add_argument('-q','--query',default='')
20
+ search.add_argument('--kind',choices=('all','tenant','user','group','organization'),default='all')
21
+ search.add_argument('-o','--organization',default='')
22
+ search.add_argument('--page',type=int,default=1);search.add_argument('--limit',type=int,default=50)
23
+ search.add_argument('--tui',action='store_true')
24
+ for name in ('check','apply'):
25
+ command=actions.add_parser(name);command.add_argument('--file',required=True)
26
+ if name=='apply':
27
+ command.add_argument('--expected-revision',required=True)
28
+ command.add_argument('--confirm',required=True,action='store_true')
29
+
30
+
31
+ def main(options):
32
+ try:
33
+ admin=AccountAdmin(options.config)
34
+ if options.admin_command=='search':
35
+ from atraxis.account_search import AccountQuery
36
+ query=AccountQuery(q=options.query,kind=options.kind,organization=options.organization,page=options.page,limit=options.limit)
37
+ if options.tui:
38
+ from atraxis.account_search_textual import run
39
+ return run(admin,query)
40
+ print(json.dumps(admin.search(query),indent=2));return 0
41
+ if options.admin_command=='tui':
42
+ from atraxis.account_textual import run
43
+ return run(admin)
44
+ data=admin.inspect()
45
+ if options.admin_command=='show':result=data
46
+ else:
47
+ request=checked_request(yaml.load(read_private(options.file,MAX_REQUEST_BYTES),Loader=_StrictLoader))
48
+ revision=options.expected_revision if options.admin_command=='apply' else data['revision']
49
+ plan=admin.prepare(request,revision)
50
+ result=({'revision':admin.commit(plan)} if options.admin_command=='apply' else
51
+ {'revision':plan['revision'],'request':plan['request'],'actor':plan['audit']})
52
+ print(json.dumps(result,indent=2));return 0
53
+ except PermissionError:
54
+ print('Account administration denied.',file=sys.stderr);return 3
55
+ except Exception:
56
+ print('Account operation unconfirmed. Check the current directory before retrying.',file=sys.stderr);return 2
@@ -0,0 +1,128 @@
1
+ """Trusted-host, data-only account backups and fresh SQLite restoration."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import stat
7
+ import tempfile
8
+
9
+ from atraxis.accounts import AccountError
10
+ from atraxis.account_storage import storage_config, open_storage
11
+ from atraxis.account_store import AccountStore
12
+ from atraxis.account_sqlite import SQLiteTransaction
13
+ from atraxis.account_lock import private_lock
14
+ from atraxis.rule_files import read_private
15
+
16
+ FORMAT = 1
17
+ MAX_BYTES = 32 * 1024 * 1024
18
+ MAX_AUDIT_ROWS = 100000
19
+ FIELDS = frozenset({'version', 'backend', 'snapshot'})
20
+
21
+
22
+ def canonical(value):
23
+ return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode()
24
+
25
+
26
+ def parent_private(path):
27
+ metadata = path.parent.lstat()
28
+ if (not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid()
29
+ or stat.S_IMODE(metadata.st_mode) & 0o077):
30
+ raise AccountError('backup_requires_private_directory')
31
+
32
+
33
+ def sync_directory(path):
34
+ fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
35
+ try: os.fsync(fd)
36
+ finally: os.close(fd)
37
+
38
+
39
+ def summary(payload):
40
+ snapshot = payload['snapshot']
41
+ return dict(version=FORMAT, backend='sqlite', revision=snapshot['revision'],
42
+ audit_entries=len(snapshot['audit']), sha256=hashlib.sha256(canonical(payload)).hexdigest())
43
+
44
+
45
+ def checked(payload):
46
+ if (type(payload) is not dict or set(payload) != FIELDS
47
+ or type(payload['version']) is not int or payload['version'] != FORMAT
48
+ or payload['backend'] != 'sqlite'):
49
+ raise AccountError('invalid_account_backup')
50
+ AccountStore._decode(payload['snapshot'])
51
+ if len(payload['snapshot']['audit']) > MAX_AUDIT_ROWS:
52
+ raise AccountError('account_backup_limit_exceeded')
53
+ return payload
54
+
55
+
56
+ def read_backup(path):
57
+ return decode_backup(read_private(path, MAX_BYTES))
58
+
59
+
60
+ def decode_backup(raw):
61
+ if len(raw) > MAX_BYTES: raise AccountError('account_backup_limit_exceeded')
62
+ outer = json.loads(raw)
63
+ if (type(outer) is not dict or set(outer) != {'sha256', 'payload'}
64
+ or outer['sha256'] != hashlib.sha256(canonical(outer['payload'])).hexdigest()):
65
+ raise AccountError('account_backup_integrity_failed')
66
+ return checked(outer['payload'])
67
+
68
+
69
+ def sqlite_adapter(config):
70
+ config = storage_config(config)
71
+ if config['backend'] != 'sqlite': raise AccountError('unsupported_account_backup_backend')
72
+ return open_storage(config)
73
+
74
+
75
+ def backup(config, output):
76
+ adapter = sqlite_adapter(config)
77
+ # One read snapshot includes directory, all audit and the membership projection.
78
+ with adapter._connect() as connection:
79
+ connection.execute('BEGIN'); adapter._schema(connection, False)
80
+ if connection.execute('SELECT COUNT(*) FROM account_audit').fetchone()[0] > MAX_AUDIT_ROWS:
81
+ raise AccountError('account_backup_limit_exceeded')
82
+ snapshot = AccountStore._decode(SQLiteTransaction(connection).read())
83
+ payload = checked(dict(version=FORMAT, backend='sqlite', snapshot=snapshot))
84
+ raw = canonical(dict(payload=payload, sha256=hashlib.sha256(canonical(payload)).hexdigest()))
85
+ if len(raw) > MAX_BYTES: raise AccountError('account_backup_limit_exceeded')
86
+ path = Path(output); parent_private(path)
87
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
88
+ try:
89
+ with os.fdopen(fd, 'wb') as stream:
90
+ stream.write(raw); stream.flush(); os.fsync(stream.fileno())
91
+ except BaseException:
92
+ path.unlink()
93
+ raise
94
+ sync_directory(path)
95
+ return summary(payload)
96
+
97
+
98
+ def restore(config, archive, *, services_stopped=False):
99
+ if services_stopped is not True: raise AccountError('services_stop_confirmation_required')
100
+ adapter = sqlite_adapter(config)
101
+ payload = read_backup(archive)
102
+ path = adapter.path; parent_private(path)
103
+ with private_lock(path):
104
+ if any(os.path.lexists(str(path) + suffix) for suffix in ('', '-wal', '-shm', '-journal')):
105
+ raise AccountError('account_restore_requires_new_database')
106
+ fd, temporary = tempfile.mkstemp(prefix='.account-restore-', suffix='.db', dir=path.parent)
107
+ os.close(fd)
108
+ temporary = Path(temporary)
109
+ try:
110
+ target = sqlite_adapter(str(temporary))
111
+ data = payload['snapshot']
112
+ with target.transaction(initialize=True) as transaction:
113
+ for event in data['audit'][:-1]: transaction.append_audit(event)
114
+ transaction.write(data)
115
+ if AccountStore._decode(transaction.read()) != data:
116
+ raise AccountError('account_restore_validation_failed')
117
+ if transaction.connection.execute('PRAGMA foreign_key_check').fetchone() is not None:
118
+ raise AccountError('account_restore_validation_failed')
119
+ # Publish only a complete, closed database; never overwrite a destination.
120
+ with temporary.open('rb') as stream: os.fsync(stream.fileno())
121
+ os.link(temporary, path)
122
+ temporary.unlink()
123
+ sync_directory(path)
124
+ finally:
125
+ for suffix in ('', '.lock', '-wal', '-shm', '-journal'):
126
+ candidate = Path(str(temporary) + suffix)
127
+ if candidate.exists(): candidate.unlink()
128
+ return summary(payload)
@@ -0,0 +1,98 @@
1
+ """Explicit, local account administration using trusted POSIX file ownership."""
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from atraxis.config import _StrictLoader
9
+ from atraxis.rule_files import read_private
10
+ from atraxis.account_storage import storage_config
11
+ from atraxis.accounts import AccountError, Directory, default_directory
12
+ from atraxis.account_store import AccountStore, load_candidate
13
+
14
+ MAX_STORAGE_CONFIG_BYTES = 65536
15
+
16
+
17
+ def add_parser(commands):
18
+ parser = commands.add_parser('accounts', help='local private account directory administration')
19
+ location = parser.add_mutually_exclusive_group(required=True)
20
+ location.add_argument('--store', help='storage URL or SQLite path')
21
+ location.add_argument('--storage-config', help='private backend/url/options YAML file')
22
+ parser.add_argument('--backend', help='installed account storage backend (with --store)')
23
+ actions = parser.add_subparsers(dest='account_command', required=True)
24
+ init = actions.add_parser('init')
25
+ init.add_argument('--subject', required=True)
26
+ init.add_argument('--administrator', action='store_true', help='explicit local bootstrap of global account administration')
27
+ actions.add_parser('show')
28
+ actions.add_parser('audit')
29
+ actions.add_parser('export')
30
+ backup = actions.add_parser('backup', help='write a private complete SQLite account archive')
31
+ backup.add_argument('--output', required=True)
32
+ restore = actions.add_parser('restore', help='restore into a new SQLite database with services stopped')
33
+ restore.add_argument('--input', required=True)
34
+ restore.add_argument('--confirm-services-stopped', action='store_true')
35
+ contexts = actions.add_parser('contexts')
36
+ contexts.add_argument('--subject', required=True)
37
+ for name in ('check', 'apply'):
38
+ action = actions.add_parser(name)
39
+ action.add_argument('--file', required=True)
40
+ if name == 'apply':
41
+ action.add_argument('--expected-revision', required=True)
42
+ action.add_argument('--confirm', action='store_true', required=True)
43
+
44
+
45
+ def main(options):
46
+ try:
47
+ if options.storage_config:
48
+ if options.backend is not None:
49
+ raise AccountError('invalid_account_storage')
50
+ path = Path(options.storage_config).absolute()
51
+ config = storage_config(yaml.load(read_private(path, MAX_STORAGE_CONFIG_BYTES), Loader=_StrictLoader), path.parent)
52
+ else:
53
+ config = {'backend': options.backend or 'sqlite', 'url': options.store}
54
+ store = AccountStore(config)
55
+ command = options.account_command
56
+ if command in ('backup', 'restore'):
57
+ from atraxis.account_backup import backup, restore
58
+ result = (backup(config, options.output) if command == 'backup' else
59
+ restore(config, options.input, services_stopped=options.confirm_services_stopped))
60
+ elif command == 'init':
61
+ directory = default_directory(options.subject)
62
+ if options.administrator:
63
+ from atraxis.account_entities import GRANT_OPERATIONS
64
+ document = directory.document()
65
+ document['entity_delegations'].append(dict(id='root-entities',subject=options.subject,scope=dict(kind='global',id=None),
66
+ operations=sorted(GRANT_OPERATIONS),parent=None,enabled=True,delegable=True))
67
+ directory = Directory(document)
68
+ result = {'revision': store.apply(directory, None)}
69
+ elif command in ('check', 'apply'):
70
+ candidate = load_candidate(options.file)
71
+ if command == 'apply':
72
+ result = {'revision': store.apply(candidate, options.expected_revision)}
73
+ else:
74
+ current = store.snapshot(audit=False)
75
+ result = {'revision': current['revision'], 'changes': {}}
76
+ for key in ('tenants', 'organizations', 'groups', 'users', 'memberships', 'delegations', 'entity_delegations'):
77
+ before = {json.dumps(item, sort_keys=True) for item in current['directory'][key]}
78
+ after = {json.dumps(item, sort_keys=True) for item in candidate.document()[key]}
79
+ result['changes'][key] = {'removed': [json.loads(item) for item in sorted(before-after)],
80
+ 'added': [json.loads(item) for item in sorted(after-before)]}
81
+ else:
82
+ snapshot = store.snapshot(audit=command=='audit')
83
+ if command == 'contexts':
84
+ directory = Directory(snapshot['directory'])
85
+ result = [{'organization': org, 'tenant': directory.organizations[org]['tenant'],
86
+ 'groups': directory.effective_groups(options.subject, org)}
87
+ for org in directory.contexts(options.subject)]
88
+ elif command == 'export':
89
+ result = snapshot['directory']
90
+ elif command == 'audit':
91
+ result = snapshot['audit']
92
+ else:
93
+ result = {'revision': snapshot['revision'], 'directory': snapshot['directory']}
94
+ print(json.dumps(result, indent=2, sort_keys=True))
95
+ return 0
96
+ except (AccountError, OSError, ValueError, TypeError, RecursionError, yaml.YAMLError):
97
+ print('Account operation failed; check private files, input and current revision.', file=sys.stderr)
98
+ return 2
@@ -0,0 +1,182 @@
1
+ """Bounded account administration; no rule/engine permissions are assigned here."""
2
+ from copy import deepcopy
3
+ import uuid
4
+
5
+ from atraxis.accounts import AccountError, identifier, require
6
+
7
+ MAX_DELEGATIONS = 1024
8
+ MAX_DELEGATION_DEPTH = 8
9
+ MEMBERSHIP_OPERATIONS = frozenset({'membership.add', 'membership.remove'})
10
+ OPERATIONS = MEMBERSHIP_OPERATIONS | {'delegation.create', 'delegation.revoke'}
11
+ GRANT_FIELDS = frozenset({'id','subject','organization','groups','operations',
12
+ 'organization_membership','delegable','enabled','parent'})
13
+
14
+
15
+ def validate_delegations(directory, entries):
16
+ require(type(entries) is list and len(entries) <= MAX_DELEGATIONS)
17
+ grants = {}
18
+ for item in entries:
19
+ require(type(item) is dict and set(item) == GRANT_FIELDS)
20
+ require(identifier(item['id']) and item['id'] not in grants)
21
+ require(identifier(item['subject']) and item['subject'] in directory.users)
22
+ require(identifier(item['organization']) and item['organization'] in directory.organizations)
23
+ require(type(item['groups']) is list and len(item['groups']) <= len(directory.groups))
24
+ require(all(identifier(g) and g in directory.groups and directory.groups[g]['organization'] == item['organization'] for g in item['groups']))
25
+ require(len(set(item['groups'])) == len(item['groups']))
26
+ require(type(item['operations']) is list and all(type(o) is str for o in item['operations']))
27
+ require(bool(item['operations']) and set(item['operations']) <= MEMBERSHIP_OPERATIONS
28
+ and len(set(item['operations'])) == len(item['operations']))
29
+ require(all(type(item[k]) is bool for k in ('organization_membership','delegable','enabled')))
30
+ require(item['organization_membership'] or bool(item['groups']))
31
+ require(item['parent'] is None or identifier(item['parent']))
32
+ grants[item['id']] = item
33
+ for grant in grants.values():
34
+ seen, subjects, current = set(), set(), grant
35
+ while current is not None:
36
+ require(current['id'] not in seen and current['subject'] not in subjects and len(seen) < MAX_DELEGATION_DEPTH)
37
+ seen.add(current['id']); subjects.add(current['subject'])
38
+ parent_id = current['parent']
39
+ if parent_id is None:
40
+ break
41
+ require(parent_id in grants)
42
+ parent = grants[parent_id]
43
+ require(parent['subject'] != current['subject'] and parent['delegable'])
44
+ require(parent['organization'] == current['organization'])
45
+ require(set(current['groups']) <= set(parent['groups']) and set(current['operations']) <= set(parent['operations']))
46
+ require(not current['organization_membership'] or parent['organization_membership'])
47
+ current = parent
48
+ return grants
49
+
50
+
51
+ def active(directory, grant):
52
+ current = grant
53
+ while current is not None:
54
+ if not current['enabled'] or not directory.allows_membership(current['subject'], current['organization']):
55
+ return False
56
+ current = directory.delegations.get(current['parent'])
57
+ return True
58
+
59
+
60
+ def owned(directory, subject):
61
+ return [g for g in directory.delegations.values() if g['subject'] == subject and active(directory,g)]
62
+
63
+
64
+ def checked_request(request):
65
+ from atraxis.account_entities import ALL_OPERATIONS, checked_request as entity_request
66
+ if type(request) is dict and request.get('operation') in ALL_OPERATIONS:
67
+ return entity_request(request)
68
+ if type(request) is not dict or request.get('operation') not in OPERATIONS:
69
+ raise AccountError('invalid_account_operation')
70
+ operation = request['operation']
71
+ fields = ({'operation','organization','subject','group'} if operation in MEMBERSHIP_OPERATIONS else
72
+ {'operation','parent','subject','groups','operations','organization_membership','delegable'}
73
+ if operation == 'delegation.create' else {'operation','grant'})
74
+ require(set(request) == fields)
75
+ if operation in MEMBERSHIP_OPERATIONS:
76
+ require(identifier(request['organization']) and identifier(request['subject']))
77
+ require(request['group'] is None or identifier(request['group']))
78
+ elif operation == 'delegation.create':
79
+ require(identifier(request['parent']) and identifier(request['subject']))
80
+ require(type(request['groups']) is list and len(request['groups']) <= 4096
81
+ and all(identifier(g) for g in request['groups']) and len(set(request['groups'])) == len(request['groups']))
82
+ require(type(request['operations']) is list and len(request['operations']) <= 2
83
+ and all(type(o) is str for o in request['operations']) and bool(request['operations'])
84
+ and set(request['operations']) <= MEMBERSHIP_OPERATIONS and len(set(request['operations'])) == len(request['operations']))
85
+ require(type(request['organization_membership']) is bool and type(request['delegable']) is bool)
86
+ else:
87
+ require(identifier(request['grant']))
88
+ return deepcopy(request)
89
+
90
+
91
+ def propose(directory, subject, request, *, new_id=None):
92
+ """Return a candidate and scoped audit metadata, or deny without side effects."""
93
+ from atraxis.accounts import Directory
94
+ from atraxis.account_entities import ALL_OPERATIONS, propose as entity_propose
95
+ if type(request) is dict and request.get('operation') in ALL_OPERATIONS:
96
+ return entity_propose(directory, subject, request, new_id=new_id or uuid.uuid4().hex)
97
+ request = checked_request(request)
98
+ operation = request['operation']
99
+ grants = owned(directory, subject)
100
+ candidate = directory.document()
101
+ if operation in MEMBERSHIP_OPERATIONS:
102
+ org, target, group = request['organization'], request['subject'], request['group']
103
+ from atraxis.account_entities import membership_grants
104
+ grants = grants + membership_grants(directory,subject,org)
105
+ if target == subject or target not in directory.users:
106
+ raise PermissionError('account_administration_denied')
107
+ if group is not None and (group not in directory.groups or directory.groups[group]['organization'] != org):
108
+ raise PermissionError('account_administration_denied')
109
+ if group is not None and (target,org,None) not in directory.memberships:
110
+ raise PermissionError('account_administration_denied')
111
+ row = {'user':target,'organization':org,'group':group}
112
+ adding = operation == 'membership.add'
113
+ exists = row in candidate['memberships']
114
+ if exists == adding:
115
+ raise AccountError('account_membership_unchanged')
116
+ if adding:
117
+ candidate['memberships'].append(row)
118
+ else:
119
+ candidate['memberships'].remove(row)
120
+ updated = Directory(candidate)
121
+ before = set(directory.effective_groups(target,org))
122
+ after = set(updated.effective_groups(target,org))
123
+ # An explicit parent membership can affect inherited descendants. Bound all effects.
124
+ affected = before ^ after
125
+ if group is not None:
126
+ affected.add(group)
127
+ if not any(g['organization'] == org and operation in g['operations']
128
+ and affected <= set(g['groups']) and (group is not None or g['organization_membership']) for g in grants):
129
+ raise PermissionError('account_administration_denied')
130
+ # Membership changes must not indirectly reactivate another administrator's chain.
131
+ if (any(g['subject'] == target and g['organization'] == org for g in directory.delegations.values())
132
+ or any(g['subject']==target for g in directory.entity_delegations.values())):
133
+ raise PermissionError('administrator_membership_requires_local_authority')
134
+ elif operation == 'delegation.create':
135
+ parent = next((g for g in grants if g['id'] == request['parent']),None)
136
+ target = request['subject']
137
+ if parent is None or not parent['delegable'] or target == subject or not directory.allows_membership(target,parent['organization']):
138
+ raise PermissionError('account_delegation_denied')
139
+ org = parent['organization']
140
+ if (not set(request['groups']) <= set(parent['groups']) or not set(request['operations']) <= set(parent['operations'])
141
+ or request['organization_membership'] and not parent['organization_membership']):
142
+ raise PermissionError('account_delegation_denied')
143
+ # Do not delegate back to an ancestor subject, even through a different grant ID.
144
+ current = parent
145
+ while current is not None:
146
+ if current['subject'] == target:
147
+ raise PermissionError('account_delegation_cycle')
148
+ current = directory.delegations.get(current['parent'])
149
+ candidate['delegations'].append(dict(id=new_id or uuid.uuid4().hex,subject=target,organization=org,
150
+ groups=request['groups'],operations=request['operations'],organization_membership=request['organization_membership'],
151
+ delegable=request['delegable'],enabled=True,parent=parent['id']))
152
+ updated = Directory(candidate)
153
+ else:
154
+ grant = directory.delegations.get(request['grant'])
155
+ parent = next((g for g in grants if grant is not None and g['id'] == grant['parent']),None)
156
+ if grant is None or parent is None or not parent['delegable'] or not grant['enabled']:
157
+ raise PermissionError('account_delegation_denied')
158
+ org, target = grant['organization'], grant['subject']
159
+ next(g for g in candidate['delegations'] if g['id']==grant['id'])['enabled']=False
160
+ updated = Directory(candidate)
161
+ return updated, {'subject':subject,'operation':operation,'organization':org,'target':target}
162
+
163
+
164
+ def view(directory, subject):
165
+ grants = owned(directory,subject)
166
+ legacy_ids = {g['id'] for g in grants}
167
+ from atraxis.account_entities import membership_grants
168
+ for organization in directory.organizations:
169
+ grants += membership_grants(directory,subject,organization)
170
+ result = []
171
+ for org in sorted({g['organization'] for g in grants}):
172
+ applicable = [g for g in grants if g['organization']==org]
173
+ groups = set().union(*(set(g['groups']) for g in applicable))
174
+ organization_membership = any(g['organization_membership'] for g in applicable)
175
+ memberships = [m for m in directory.document()['memberships'] if m['organization']==org
176
+ and (m['group'] in groups or m['group'] is None and organization_membership)]
177
+ tenant = directory.tenants[directory.organizations[org]['tenant']]
178
+ result.append({'id':org,'name':directory.organizations[org]['name'],'tenant':tenant['name'],
179
+ 'groups':[{'id':g,'name':directory.groups[g]['name']} for g in sorted(groups)],
180
+ 'memberships':memberships,'grants':deepcopy(applicable),
181
+ 'delegated':[deepcopy(g) for g in directory.delegations.values() if g['parent'] in legacy_ids & {p['id'] for p in applicable}]})
182
+ return result