From 25c151841b16a5fbe5459ccd53e95a79e60ddcdb Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 25 Oct 2026 18:34:59 +0100 Subject: [PATCH] feature: service accounts have a display name and a description, which the router sets for its internal ones. --- ADR.md | 21 ++++-- README.md | 33 +++++---- debian-package/debian/changelog | 18 +++++ debian-package/src/usr/lib/vpn-router/setup | 14 ++-- .../src/usr/lib/vpn-router/vpnrouter_ldap.py | 7 +- .../src/usr/lib/vpn-router/vpnrouter_users.py | 74 ++++++++++++++++--- debian-package/src/usr/lib/vpn-router/web | 12 +-- debian-package/src/usr/sbin/vpn-router-user | 12 ++- 8 files changed, 139 insertions(+), 52 deletions(-) diff --git a/ADR.md b/ADR.md index 75f0d34..9f125f0 100644 --- a/ADR.md +++ b/ADR.md @@ -465,11 +465,14 @@ Decision: The package defines its schema, `cn=vpn-router,cn=schema,cn=config`, w `vpnrouter_ldap.provision` adds when missing and keeps as `SCHEMA_ATTRIBUTE_TYPES` and `SCHEMA_OBJECT_CLASSES`. Its OID is `2.25.98477352595735834532404256531620013001`, made from a random UUID under the arc 2.25 (ITU-T X.667), which needs no registration; attribute types are -under `.1` and object classes under `.2`. The first version has the attribute `vpnRouterScope` -(multi-valued, case-insensitive strings) and the auxiliary class `vpnRouterAccount`, -`MAY ( mail $ vpnRouterScope )`. Manager-only accounts have the class from their creation, so an -e-mail address can be added later without changing their classes; VPN users get it once scopes are -used. +under `.1` and object classes under `.2`. It has the attribute `vpnRouterScope` (multi-valued, +case-insensitive strings) and the auxiliary class `vpnRouterAccount`, +`MAY ( mail $ vpnRouterScope $ displayName $ description )`; `displayName` and `description`, the +standard attributes, are the display name and the description of a service account, which +`account` cannot hold. Manager-only accounts have the class from their creation, so an e-mail +address can be added later without changing their classes; VPN users get it once scopes are used. +`vpnrouter_ldap.provision` replaces the definitions in `cn=config` that differ, so an extended class +reaches an existing directory at the next setup run. Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their OIDs continue the arcs. The scope `service` marks a service account (ADR-23). The OID must never @@ -572,9 +575,11 @@ which another system signs in with to use a router service. Administrators add s with `vpn-router-user add --service` and the web service's `/service-accounts`. `/service-accounts` is a path of its own, apart from `/users`, which does not list or serve them: a service account has no names, certificate, profile or TOTP, and is not a person an -administrator manages with the users. Internal service accounts are those the router creates and -manages for its own services (`vpnrouter_users.internal_accounts`), so the system, not an -administrator, owns them; at present that is the metrics service's. `/service-accounts` marks +administrator manages with the users. A service account has a display name and a description +besides its name, so an administrator can tell what each is for. Internal service accounts are +those the router creates and manages for its own services (`vpnrouter_users.internal_accounts`), +with every property, display name and description included, so the system, not an +administrator, owns them; at present that is the metrics service's, which setup names Metrics. `/service-accounts` marks them as `internal` and only lists them: every other request on one, and putting one in or taking one out of a group at `/groups`, gets 403, whatever the token. The router changes them through the directory's user-manager account, not the API, and root may still with `vpn-router-user`. For the diff --git a/README.md b/README.md index d5f568f..a416d59 100644 --- a/README.md +++ b/README.md @@ -393,9 +393,10 @@ is switched on: `account` for a Manager-only account (see Managing users); - the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID `2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class - `vpnRouterAccount` with `MAY ( mail $ vpnRouterScope )`, which Manager-only and service accounts - have, and the attribute `vpnRouterScope`, authorization scopes, of which `service` marks a - service account; + `vpnRouterAccount` with `MAY ( mail $ vpnRouterScope $ displayName $ description )`, which + Manager-only and service accounts have, and the attribute `vpnRouterScope`, authorization + scopes, of which `service` marks a service account; `displayName` and `description` are the + display name and the description of a service account; - `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in `member` and an optional `description`; - `cn=user-manager,ou=services` is the account that changes users and groups; it may write @@ -429,10 +430,12 @@ it is added: the router. `add` without the first and the last name adds one; its names cannot be set later; - a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another system signs in with to use a router service, such as an OpenTelemetry Collector reading - `/metrics`; it cannot sign in to `/auth`. `add --service` adds one, and the web service manages - them at `/service-accounts`, not at `/users`. Internal service accounts are those the router - creates and manages for its own services; at present that is the metrics service's, the account - `metrics.service_account` names while metrics are enabled (see Metrics). The API only lists + `/metrics`; it cannot sign in to `/auth`. Besides its name, it has a display name and a + description. `add --service` adds one, and the web service manages them at `/service-accounts`, + not at `/users`. Internal service accounts are those the router creates and manages for its own + services, display name and description included; at present that is the metrics service's, the + account `metrics.service_account` names while metrics are enabled (see Metrics), which + `vpn-router-setup` names Metrics. The API only lists them: `/service-accounts` refuses every other request on one, and `/groups` refuses to put one in or take one out of a group, with 403. @@ -466,8 +469,9 @@ vpn-router-user otp-remove alice | `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP | | `add --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate | | `add [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused | -| `add --service [--email E]` | Adds a service account, with the e-mail address when given; the names are refused | +| `add --service [--display-name N] [--description D] [--email E]` | Adds a service account, with its display name, description and e-mail address when given; the names are refused | | `update [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address | +| `update [--display-name N] [--description D] [--email E]` | Changes the display name, the description or the e-mail address of a service account; `''` removes one | | `passwd ` | Sets the password | | `disable `, `enable ` | Locks the user out of OpenVPN and `/auth`, and lets the user in again | | `unlock ` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled | @@ -752,18 +756,19 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ | Request | Body | Answer | | --- | --- | --- | -| `GET /service-accounts` | | `{"service_accounts": [{"name", "internal", "enabled", "locked", "email", ...}, ...]}`, each as `/users` shows a user | -| `POST /service-accounts` | `{"name", "password", "email"?}` | 201 and the account | -| `PUT /service-accounts/` | `{"email"}`; `""` removes it | the account | +| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", ...}, ...]}`, each as `/users` shows a user | +| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?}` | 201 and the account | +| `PUT /service-accounts/` | any of `{"display_name", "description", "email"}`; `""` removes one | the account | | `PUT /service-accounts//password` | `{"password": ...}` | the account | | `POST /service-accounts//disable` | | the account | | `POST /service-accounts//enable` | | the account | | `POST /service-accounts//unlock` | | the account | | `DELETE /service-accounts/` | | `{"name", "removed": true}` | -The internal service accounts, `internal` true, are only listed: every other request on one gets -403, as the router manages them. A failure is otherwise as for `/users`, with 404 also for a -name that is not a service account. +A display name has at most 64 printable characters and a description at most 256. The internal +service accounts, `internal` true, are only listed: every other request on one gets 403, as the +router manages them, their display name and description included. A failure is otherwise as for +`/users`, with 404 also for a name that is not a service account. ### The /account API diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index f14e62d..e42cdfd 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,21 @@ +vpn-router (0.19.0-1) unstable; urgency=medium + + feature: service accounts have a display name and a description, which the router sets for its internal ones. + + * vpnRouterAccount of the package's schema also takes displayName and + description; vpn-router-setup updates the schema of an existing + directory. + * /service-accounts shows display_name and description, takes them when + adding a service account and changes them with PUT; "" removes one. + /users shows them as null. + * vpn-router-user add --service and update take --display-name and + --description. + * vpn-router-setup gives the metrics service's internal account the + display name Metrics and its description, and sets them again when + they differ. + + -- Slawomir Koszewski Mon, 26 Oct 2026 20:00:00 +0100 + vpn-router (0.18.3-1) unstable; urgency=medium fix: internal service accounts are the router's own, of which the metrics service's is the one at present, and the documentation says what a service account is. diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index 29170d4..f5e6fa3 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -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) diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py index 7033a80..05f756e 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py @@ -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. diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index 8b45d9d..19d1e7c 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -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) diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 2cf78f5..8b6fc9f 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -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/ change the e-mail address: {"email"}; "" removes it + POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?} + PUT /service-accounts/ change any of {"display_name", "description", "email"}; "" removes one PUT /service-accounts//password set the password: {"password": ...} POST /service-accounts//disable lock it out POST /service-accounts//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'} diff --git a/debian-package/src/usr/sbin/vpn-router-user b/debian-package/src/usr/sbin/vpn-router-user index ea7b674..345a311 100755 --- a/debian-package/src/usr/sbin/vpn-router-user +++ b/debian-package/src/usr/sbin/vpn-router-user @@ -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 --first-name F --last-name L --email E (password on stdin) vpn-router-user [--json] add [--email E] (a Manager-only account; password on stdin) - vpn-router-user [--json] add --service [--email E] (a service account; password on stdin) + vpn-router-user [--json] add --service [--display-name N] [--description D] [--email E] + (a service account; password on stdin) vpn-router-user [--json] update [--first-name F] [--last-name L] [--email E] + vpn-router-user [--json] update [--display-name N] [--description D] [--email E] (a service account) vpn-router-user [--json] passwd (password on stdin) vpn-router-user [--json] disable vpn-router-user [--json] enable @@ -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':