feature: service accounts have a display name and a description, which the router sets for its internal ones.
Release / release (push) Successful in 20s

This commit is contained in:
slawek committed 2026-10-25 18:34:59 +01:00
1 parent 68ebd541b2
commit 25c151841b
8 files changed
+139 -52

No files matched your search

+9 -5
View File
@@ -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)
+6 -6
View File
@@ -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'}