feature: service accounts have a display name and a description, which the router sets for its internal ones.
Release / release (push) Successful in 20s
Release / release (push) Successful in 20s
This commit is contained in:
1 parent
68ebd541b2
commit
25c151841b
8 files changed
+139
-52
No files matched your search
@@ -458,14 +458,16 @@ def manage_admin():
|
||||
def manage_metrics_account(ctx):
|
||||
"""Keep the service account metrics.service_account names, whose password is in METRICS_SECRET; the problem, or ''.
|
||||
|
||||
While metrics are enabled, setup owns the account: it adds it as a service account with a random password when
|
||||
it is missing, and gives it a new random password, ending any lockout, when METRICS_SECRET is missing or does not
|
||||
hold its password. A disabled account is left as it is. An account of that name that is not a service account is
|
||||
left alone and is a problem.
|
||||
While metrics are enabled, setup owns the account, an internal service account: it adds it as a service account
|
||||
with a random password and the labels of vpnrouter_users.internal_accounts when it is missing, sets those labels
|
||||
when they differ, and gives it a new random password, ending any lockout, when METRICS_SECRET is missing or does
|
||||
not hold its password. The password of a disabled account is left as it is. An account of that name that is not a
|
||||
service account is left alone and is a problem.
|
||||
"""
|
||||
if not vpnrouter.metrics_ready(ctx):
|
||||
return ''
|
||||
name = ctx['metrics_service_account']
|
||||
wanted = vpnrouter_users.internal_accounts().get(name, {})
|
||||
try:
|
||||
vpnrouter_users.check_name(name)
|
||||
try:
|
||||
@@ -481,6 +483,8 @@ def manage_metrics_account(ctx):
|
||||
current = None
|
||||
if current is not None and not vpnrouter_users.service(current):
|
||||
return f'metrics.service_account: {name} is a user, not a service account'
|
||||
if current is not None and any(vpnrouter_users.summary(current)[key] != value for key, value in wanted.items()):
|
||||
vpnrouter_users.update_service(connection, name, wanted)
|
||||
# An administrator who disabled the account meant it; its password is left as it is.
|
||||
if current is not None and not vpnrouter_users.summary(current)['enabled']:
|
||||
return ''
|
||||
@@ -497,7 +501,7 @@ def manage_metrics_account(ctx):
|
||||
METRICS_SECRET.chmod(0o600)
|
||||
METRICS_SECRET.write_text(password + '\n', encoding='utf-8')
|
||||
if current is None:
|
||||
vpnrouter_users.add(connection, name, password, {}, service=True)
|
||||
vpnrouter_users.add(connection, name, password, wanted, service=True)
|
||||
else:
|
||||
vpnrouter_users.passwd(connection, name, password)
|
||||
vpnrouter_users.unlock(connection, name)
|
||||
|
||||
@@ -52,8 +52,9 @@ OVERLAYS = ('ppolicy', 'otp')
|
||||
|
||||
#: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types
|
||||
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
|
||||
#: package to a user of either kind: mail, for a Manager-only account, and vpnRouterScope,
|
||||
#: authorization scopes, of which service marks a service account.
|
||||
#: package to a user of either kind: mail, for a Manager-only account, vpnRouterScope,
|
||||
#: authorization scopes, of which service marks a service account, and displayName and
|
||||
#: description, the labels of a service account.
|
||||
SCHEMA_OID = '2.25.98477352595735834532404256531620013001'
|
||||
SCHEMA_NAME = 'vpn-router'
|
||||
SCHEMA_ATTRIBUTE_TYPES = (
|
||||
@@ -62,7 +63,7 @@ SCHEMA_ATTRIBUTE_TYPES = (
|
||||
)
|
||||
SCHEMA_OBJECT_CLASSES = (
|
||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope ) )",
|
||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description ) )",
|
||||
)
|
||||
|
||||
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
||||
|
||||
@@ -8,9 +8,10 @@ simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a
|
||||
optionally an e-mail address, no names, no certificate and so no VPN access; it is added when
|
||||
neither the first nor the last name is given, and can sign in to the web service. A service
|
||||
account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which another
|
||||
system signs in with to use a router service, and which cannot sign in to the web service. The
|
||||
internal service accounts are those the router creates and manages for its own services
|
||||
(internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||
system signs in with to use a router service, and which cannot sign in to the web service; it has
|
||||
a display name and a description (labels), in displayName and description of vpnRouterAccount. The
|
||||
internal service accounts are those the router creates and manages for its own services, labels
|
||||
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||
Users exist whether or not a P2S system is enabled.
|
||||
|
||||
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
|
||||
@@ -65,8 +66,11 @@ EMAIL = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
||||
EMAIL_MAX = 254
|
||||
#: The keys of the person attributes and their LDAP attributes.
|
||||
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
|
||||
#: The keys of the labels of a service account and their LDAP attributes.
|
||||
LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
||||
#: The attributes read for a user.
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope']
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||
'displayName', 'description']
|
||||
#: The vpnRouterScope of a service account.
|
||||
SERVICE_SCOPE = 'service'
|
||||
#: The object classes of a VPN user and of a Manager-only account.
|
||||
@@ -147,6 +151,26 @@ def person(values, required):
|
||||
return found
|
||||
|
||||
|
||||
def labels(values):
|
||||
"""The labels of a service account in values ({display_name, description}), checked, as {LDAP attribute: value}.
|
||||
|
||||
A key whose value is None is not given; '' means none. A display name has at most PERSON_NAME_MAX printable
|
||||
characters and a description at most DESCRIPTION_MAX.
|
||||
"""
|
||||
found = {}
|
||||
name = values.get('display_name')
|
||||
if name is not None:
|
||||
if not isinstance(name, str):
|
||||
raise Failure('display_name must be a string', 2)
|
||||
name = name.strip()
|
||||
if len(name) > PERSON_NAME_MAX or any(not character.isprintable() for character in name):
|
||||
raise Failure(f'the display name must have at most {PERSON_NAME_MAX} printable characters', 2)
|
||||
found['displayName'] = name
|
||||
if values.get('description') is not None:
|
||||
found['description'] = check_description(values['description'])
|
||||
return found
|
||||
|
||||
|
||||
def dn_of(name):
|
||||
return f'uid={name},{vpnrouter_ldap.USERS}'
|
||||
|
||||
@@ -195,17 +219,23 @@ def service(attributes):
|
||||
|
||||
|
||||
def internal_accounts():
|
||||
"""The names of the internal service accounts, which the router creates and manages for its own services: the one
|
||||
metrics.service_account names while metrics are enabled."""
|
||||
"""The internal service accounts, which the router creates and manages for its own services, by name, each with
|
||||
the labels the router gives it ({display_name, description}): the one metrics.service_account names while metrics
|
||||
are enabled."""
|
||||
try:
|
||||
metrics = vpnrouter.load_config()['metrics']
|
||||
except SystemExit:
|
||||
return set()
|
||||
return {metrics['service_account']} if metrics['enabled'] else set()
|
||||
return {}
|
||||
if not metrics['enabled']:
|
||||
return {}
|
||||
return {metrics['service_account']: {
|
||||
'display_name': 'Metrics',
|
||||
'description': 'The account an OpenTelemetry Collector signs in with to read /metrics of the metrics service.',
|
||||
}}
|
||||
|
||||
|
||||
def summary(attributes):
|
||||
"""A user as the answers show it; a person attribute the entry does not have is None.
|
||||
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
|
||||
|
||||
internal is whether it is an internal service account (internal_accounts).
|
||||
"""
|
||||
@@ -221,7 +251,7 @@ def summary(attributes):
|
||||
'certificate': certificate(name)[0].is_file(),
|
||||
'otp': 'oathTOTPToken' in attributes,
|
||||
}
|
||||
for key, attribute in PERSON:
|
||||
for key, attribute in PERSON + LABELS:
|
||||
values = attributes.get(attribute)
|
||||
user[key] = values[0].decode() if values else None
|
||||
return user
|
||||
@@ -242,12 +272,14 @@ def list_users(connection):
|
||||
def add(connection, name, password, values, service=False):
|
||||
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
|
||||
with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the
|
||||
e-mail address too."""
|
||||
e-mail address and its labels (labels)."""
|
||||
check_name(name)
|
||||
details = person(values, required=False)
|
||||
vpn_user = 'givenName' in details or 'sn' in details
|
||||
if service and vpn_user:
|
||||
raise Failure('a service account has no first or last name', 2)
|
||||
if service:
|
||||
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
||||
if vpn_user and len(details) != len(PERSON):
|
||||
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
|
||||
check_password(password)
|
||||
@@ -277,9 +309,12 @@ def add(connection, name, password, values, service=False):
|
||||
def update(connection, name, values):
|
||||
"""Change the person attributes values gives; the cn follows the first and the last name.
|
||||
|
||||
A Manager-only account has only the e-mail address, which '' removes.
|
||||
A Manager-only account has only the e-mail address, which '' removes; a service account also its labels
|
||||
(update_service).
|
||||
"""
|
||||
current = entry(connection, name)
|
||||
if service(current):
|
||||
return update_service(connection, name, values)
|
||||
if manager_only(current):
|
||||
if values.get('first_name') is not None or values.get('last_name') is not None:
|
||||
raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2)
|
||||
@@ -303,6 +338,21 @@ def update(connection, name, values):
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def update_service(connection, name, values):
|
||||
"""Change any of the e-mail address and the labels (labels) of a service account that values gives; '' removes one."""
|
||||
if not service(entry(connection, name)):
|
||||
raise Failure(f'{name} is not a service account', 3)
|
||||
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()]
|
||||
email = values.get('email')
|
||||
if email is not None:
|
||||
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
|
||||
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
|
||||
if not changes:
|
||||
raise Failure('give email, display_name or description', 2)
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def passwd(connection, name, password):
|
||||
check_password(password)
|
||||
entry(connection, name)
|
||||
|
||||
@@ -508,8 +508,8 @@ def service_accounts(method, path, body):
|
||||
"""Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /service-accounts the service accounts: {"service_accounts": [...]}
|
||||
POST /service-accounts add one: {"name", "password", "email"?}
|
||||
PUT /service-accounts/<name> change the e-mail address: {"email"}; "" removes it
|
||||
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?}
|
||||
PUT /service-accounts/<name> change any of {"display_name", "description", "email"}; "" removes one
|
||||
PUT /service-accounts/<name>/password set the password: {"password": ...}
|
||||
POST /service-accounts/<name>/disable lock it out
|
||||
POST /service-accounts/<name>/enable let it in again
|
||||
@@ -525,13 +525,13 @@ def service_accounts(method, path, body):
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
email = {'email': body.get('email')}
|
||||
settings = {key: body.get(key) for key in ('email', 'display_name', 'description')}
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: {
|
||||
'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']]},
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), email,
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), settings,
|
||||
service=True),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, email),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update_service(connection, name, settings),
|
||||
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
||||
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
|
||||
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
|
||||
@@ -575,7 +575,7 @@ def groups(state, method, path, body):
|
||||
route = (method, len(parts), parts[2] if len(parts) > 2 else None)
|
||||
members = body.get('members') if route == ('POST', 1, None) else [body.get('user')] if route == ('POST', 3, 'members') \
|
||||
else [parts[3]] if route == ('DELETE', 4, 'members') else []
|
||||
internal = sorted(vpnrouter_users.internal_accounts().intersection(
|
||||
internal = sorted(set(vpnrouter_users.internal_accounts()).intersection(
|
||||
member for member in (members if isinstance(members, list) else []) if isinstance(member, str)))
|
||||
if internal:
|
||||
return 403, {'error': f'{", ".join(internal)}: internal service accounts, which the router manages, are not group members'}
|
||||
|
||||
@@ -11,8 +11,10 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
vpn-router-user [--json] list
|
||||
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
|
||||
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
|
||||
vpn-router-user [--json] add <name> --service [--email E] (a service account; password on stdin)
|
||||
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
|
||||
(a service account; password on stdin)
|
||||
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
|
||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E] (a service account)
|
||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||
vpn-router-user [--json] disable <name>
|
||||
vpn-router-user [--json] enable <name>
|
||||
@@ -57,7 +59,9 @@ HELP = {
|
||||
}
|
||||
|
||||
#: The help of the person options.
|
||||
PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address'}
|
||||
PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address',
|
||||
'display_name': "a service account's display name; '' removes it",
|
||||
'description': "a service account's description; '' removes it"}
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
@@ -76,7 +80,7 @@ def parse(argv):
|
||||
if command != 'list':
|
||||
sub.add_argument('name', help='the user name')
|
||||
if command in ('add', 'update'):
|
||||
for key, _ in vpnrouter_users.PERSON:
|
||||
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
|
||||
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
|
||||
if command == 'add':
|
||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||
@@ -95,7 +99,7 @@ def run(connection, options, secret):
|
||||
command = options.command
|
||||
if command == 'list':
|
||||
return vpnrouter_users.list_users(connection)
|
||||
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON}
|
||||
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
|
||||
if command == 'add':
|
||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||
if command == 'update':
|
||||
|
||||
Reference in new issue
Block a user