record-compat: a refused section costs its file, not the run
A recording runs against a server that may not be up again soon, so an administrator missing one permission shouldn't throw away the whole pass. Each of the three is recorded on its own now: what the server allows is written, what it refuses is named at the end with the permission it wants, and the exit is still non-zero so an incomplete recording can't pass for a finished one. A refusal used to print the raw JMAP error. It now reads, for example, "[email protected] may not x:Tenant/get: You are not authorized to perform this action (needs sysTenantGet)". Checked both ways: the refusal path against a stubbed client, where the other two sections still record; the whole thing against a live test server, which recorded 3 tenants and 53 masked addresses and exited 0.
This commit is contained in:
+73
-18
@@ -53,6 +53,33 @@ def fail(msg, code=2):
|
|||||||
sys.exit(code)
|
sys.exit(code)
|
||||||
|
|
||||||
|
|
||||||
|
# The permission each call needs, so a refusal says what to grant rather
|
||||||
|
# than only that something was refused.
|
||||||
|
PERMISSION = {
|
||||||
|
'x:Tenant/get': 'sysTenantGet',
|
||||||
|
'x:Account/query': 'sysAccountQuery',
|
||||||
|
'x:Domain/query': 'sysDomainQuery',
|
||||||
|
'x:MaskedEmail/get': 'sysMaskedEmailGet',
|
||||||
|
'x:ArchivedItem/get': 'sysArchivedItemGet',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Refused(Exception):
|
||||||
|
"""The server allowed the sign-in but not the call."""
|
||||||
|
|
||||||
|
def __init__(self, who, method, error):
|
||||||
|
self.who = who
|
||||||
|
self.method = method
|
||||||
|
self.error = error
|
||||||
|
super().__init__(str(self))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
needed = PERMISSION.get(self.method)
|
||||||
|
detail = self.error.get('description') or json.dumps(self.error)[:120]
|
||||||
|
return (f'{self.who} may not {self.method}: {detail}'
|
||||||
|
+ (f' (needs {needed})' if needed else ''))
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
"""One signed-in identity on the Enterprise server."""
|
"""One signed-in identity on the Enterprise server."""
|
||||||
|
|
||||||
@@ -112,7 +139,7 @@ class Client:
|
|||||||
f'{json.dumps(response)[:200]}', code=1)
|
f'{json.dumps(response)[:200]}', code=1)
|
||||||
name, result, _ = calls[0]
|
name, result, _ = calls[0]
|
||||||
if name == 'error':
|
if name == 'error':
|
||||||
fail(f'{method} refused: {json.dumps(result)[:200]}', code=1)
|
raise Refused(self.name, method, result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get(self, object_type, account_id=None, ids=None):
|
def get(self, object_type, account_id=None, ids=None):
|
||||||
@@ -241,34 +268,62 @@ def main():
|
|||||||
f'{args.server.rstrip("/")}/jmap/session', code=1)
|
f'{args.server.rstrip("/")}/jmap/session', code=1)
|
||||||
|
|
||||||
os.makedirs(args.out, exist_ok=True)
|
os.makedirs(args.out, exist_ok=True)
|
||||||
account_ids = admin.query_ids('Account')
|
try:
|
||||||
|
account_ids = admin.query_ids('Account')
|
||||||
|
except Refused as refused:
|
||||||
|
fail(f'{refused}\nWithout the accounts nothing else can be recorded.', code=1)
|
||||||
if not account_ids:
|
if not account_ids:
|
||||||
fail(f'{admin.name} sees no accounts: either the wrong server, or an '
|
fail(f'{admin.name} sees no accounts: either the wrong server, or an '
|
||||||
f'administrator without the run of it', code=1)
|
f'administrator without the run of it', code=1)
|
||||||
|
|
||||||
expected = record_tenants(admin, tenant_admins)
|
# A section the server refuses costs that file, not the run: this passes
|
||||||
masks = record_masks(admin, account_ids)
|
# over a live server that may not be up again soon, so record what this
|
||||||
archived = record_archived(admin, account_ids)
|
# administrator is allowed to and say plainly what is missing.
|
||||||
|
missing = []
|
||||||
|
|
||||||
|
def section(name, record):
|
||||||
|
try:
|
||||||
|
return record()
|
||||||
|
except Refused as refused:
|
||||||
|
missing.append((name, refused))
|
||||||
|
return None
|
||||||
|
|
||||||
|
expected = section('expected.json', lambda: record_tenants(admin, tenant_admins))
|
||||||
|
masks = section('masks.json', lambda: record_masks(admin, account_ids))
|
||||||
|
archived = section('archived.json', lambda: record_archived(admin, account_ids))
|
||||||
|
|
||||||
|
written = []
|
||||||
|
if expected is not None:
|
||||||
|
written.append(('INBUXA_COMPAT_EXPECTED',
|
||||||
|
write(os.path.join(args.out, 'expected.json'), expected, private=True),
|
||||||
|
f'{len(expected["tenants"])} tenants, '
|
||||||
|
f'{len(expected["tenantAdmins"])} tenant admins'))
|
||||||
|
if masks is not None:
|
||||||
|
written.append(('INBUXA_COMPAT_MASKS',
|
||||||
|
write(os.path.join(args.out, 'masks.json'), masks),
|
||||||
|
f'{len(masks)} masked addresses'))
|
||||||
|
if archived is not None:
|
||||||
|
written.append(('INBUXA_COMPAT_ARCHIVED',
|
||||||
|
write(os.path.join(args.out, 'archived.json'), archived),
|
||||||
|
f'{len(archived)} archived items'))
|
||||||
|
|
||||||
paths = [
|
|
||||||
('INBUXA_COMPAT_EXPECTED', write(os.path.join(args.out, 'expected.json'),
|
|
||||||
expected, private=True),
|
|
||||||
f'{len(expected["tenants"])} tenants, '
|
|
||||||
f'{len(expected["tenantAdmins"])} tenant admins'),
|
|
||||||
('INBUXA_COMPAT_MASKS', write(os.path.join(args.out, 'masks.json'), masks),
|
|
||||||
f'{len(masks)} masked addresses'),
|
|
||||||
('INBUXA_COMPAT_ARCHIVED', write(os.path.join(args.out, 'archived.json'), archived),
|
|
||||||
f'{len(archived)} archived items'),
|
|
||||||
]
|
|
||||||
print(f'Recorded from {args.server} as {admin.name}, across {len(account_ids)} accounts:')
|
print(f'Recorded from {args.server} as {admin.name}, across {len(account_ids)} accounts:')
|
||||||
for variable, path, count in paths:
|
for variable, path, count in written:
|
||||||
print(f' {variable}={path} ({count})')
|
print(f' {variable}={path} ({count})')
|
||||||
if not tenant_admins and expected['tenants']:
|
if expected is not None and not tenant_admins and expected['tenants']:
|
||||||
print('\nNo --tenant-admin was given, so tenantAdmins is empty and '
|
print('\nNo --tenant-admin was given, so tenantAdmins is empty and '
|
||||||
"tenant_compat checks only the tenants themselves.\n"
|
"tenant_compat checks only the tenants themselves.\n"
|
||||||
'Pass each tenant administrator to check what it can see.',
|
'Pass each tenant administrator to check what it can see.',
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
print('\nexpected.json holds those passwords; it is written 0600.', file=sys.stderr)
|
if expected is not None:
|
||||||
|
print('\nexpected.json holds those passwords; it is written 0600.', file=sys.stderr)
|
||||||
|
if missing:
|
||||||
|
print('\nNot recorded, and its test cannot run without it:', file=sys.stderr)
|
||||||
|
for name, refused in missing:
|
||||||
|
print(f' {name}: {refused}', file=sys.stderr)
|
||||||
|
print('Grant the permission and run again; what was written above stands.',
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
Reference in New Issue
Block a user