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.
- atraxis/__init__.py +1 -0
- atraxis/__main__.py +3 -0
- atraxis/account_admin.py +140 -0
- atraxis/account_admin_commands.py +56 -0
- atraxis/account_backup.py +128 -0
- atraxis/account_commands.py +98 -0
- atraxis/account_delegation.py +182 -0
- atraxis/account_entities.py +223 -0
- atraxis/account_forms.py +23 -0
- atraxis/account_json.py +88 -0
- atraxis/account_lock.py +24 -0
- atraxis/account_schema.py +56 -0
- atraxis/account_search.py +68 -0
- atraxis/account_search_textual.py +86 -0
- atraxis/account_sqlite.py +220 -0
- atraxis/account_storage.py +78 -0
- atraxis/account_store.py +130 -0
- atraxis/account_textual.py +114 -0
- atraxis/accounts.py +147 -0
- atraxis/administration_cli.py +43 -0
- atraxis/cli.py +151 -0
- atraxis/client.py +231 -0
- atraxis/client_config.py +36 -0
- atraxis/collection.py +77 -0
- atraxis/config.py +194 -0
- atraxis/configuration_admin.py +208 -0
- atraxis/configuration_commands.py +43 -0
- atraxis/doctor.py +199 -0
- atraxis/installation/__init__.py +1 -0
- atraxis/installation/deployment.py +208 -0
- atraxis/installation/engine.py +86 -0
- atraxis/installation/filesystem.py +124 -0
- atraxis/installation/host.py +60 -0
- atraxis/installation/material.py +26 -0
- atraxis/installation/probe.py +128 -0
- atraxis/installation/tls.py +50 -0
- atraxis/installation/validation.py +224 -0
- atraxis/installation/wizard.py +178 -0
- atraxis/installation/worker.py +33 -0
- atraxis/intervention_search.py +112 -0
- atraxis/intervention_search_textual.py +58 -0
- atraxis/inventory_access.py +164 -0
- atraxis/inventory_admin.py +165 -0
- atraxis/inventory_commands.py +41 -0
- atraxis/inventory_search.py +119 -0
- atraxis/inventory_textual.py +122 -0
- atraxis/models.py +85 -0
- atraxis/observation_codec.py +184 -0
- atraxis/organization_context.py +36 -0
- atraxis/parsing.py +264 -0
- atraxis/prometheus.py +140 -0
- atraxis/prometheus_certlord.py +21 -0
- atraxis/prometheus_monit.py +21 -0
- atraxis/prometheus_observation.py +254 -0
- atraxis/read_request.py +22 -0
- atraxis/resource_scopes.py +130 -0
- atraxis/retention.py +59 -0
- atraxis/rule_commands.py +247 -0
- atraxis/rule_files.py +18 -0
- atraxis/rule_inputs.py +26 -0
- atraxis/rule_observation_config.py +37 -0
- atraxis/rule_observation_dispatch.py +34 -0
- atraxis/rule_observations.py +134 -0
- atraxis/rule_operator.py +160 -0
- atraxis/rule_protocol.py +3 -0
- atraxis/rule_remote.py +23 -0
- atraxis/rule_remote_worker.py +5 -0
- atraxis/rule_server.py +162 -0
- atraxis/rule_textual.py +305 -0
- atraxis/rule_tui.py +153 -0
- atraxis/rule_workbench.py +283 -0
- atraxis/rules.py +2 -0
- atraxis/search_client.py +66 -0
- atraxis/search_commands.py +55 -0
- atraxis/search_textual.py +107 -0
- atraxis/server.py +331 -0
- atraxis/service.py +101 -0
- atraxis/setup.py +117 -0
- atraxis/source_adapters/__init__.py +20 -0
- atraxis/source_adapters/certlord.py +16 -0
- atraxis/source_adapters/contracts.py +40 -0
- atraxis/source_adapters/monit_docker.py +16 -0
- atraxis/source_adapters/prometheus.py +24 -0
- atraxis/source_adapters/prometheus_certlord.py +41 -0
- atraxis/source_adapters/prometheus_monit.py +42 -0
- atraxis/source_config.py +181 -0
- atraxis/transport.py +119 -0
- atraxis/transport_worker.py +143 -0
- atraxis/web.py +166 -0
- atraxis/web_account_search.py +23 -0
- atraxis/web_accounts.py +134 -0
- atraxis/web_configuration.py +115 -0
- atraxis/web_dispatch.py +171 -0
- atraxis/web_intervention_detail.py +180 -0
- atraxis/web_intervention_search.py +59 -0
- atraxis/web_inventory_admin.py +108 -0
- atraxis/web_inventory_search.py +65 -0
- atraxis/web_observations.py +100 -0
- atraxis/web_organization_context.py +94 -0
- atraxis/web_reconciliation.py +146 -0
- atraxis/web_rule_activation.py +195 -0
- atraxis/web_rule_audit.py +177 -0
- atraxis/web_rule_creation.py +142 -0
- atraxis/web_rule_editor.py +291 -0
- atraxis/web_rule_retirement.py +88 -0
- atraxis/web_rules.py +225 -0
- atraxis/web_simulation.py +191 -0
- atraxis-1.0.2.dist-info/METADATA +507 -0
- atraxis-1.0.2.dist-info/RECORD +113 -0
- atraxis-1.0.2.dist-info/WHEEL +5 -0
- atraxis-1.0.2.dist-info/entry_points.txt +2 -0
- atraxis-1.0.2.dist-info/licenses/LICENSE +232 -0
- 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
atraxis/account_admin.py
ADDED
|
@@ -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
|