# vim: ts=4 sw=4 et
# -*- coding: utf-8 -*-
# © 2021 The Board of Trustees of the Leland Stanford Junior University.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# This file has some references to classes that are defined in the same
# file. Pythons older than 3.14 (which implements PEP 649) cannot handle that
# natively without this import.
# NOTE: At some point in the future, this annodation will be deprecated.
from __future__ import annotations
# Start with stdlib imports
import abc
import dataclasses
import enum
import logging
from typing import Any, Union
# Set up logging
logger = logging.getLogger(__name__)
debug = logger.debug
info = logger.info
__all__ = (
'ServiceStatus',
'AccountService',
'AccountServiceKerberos',
'AccountServiceLibrary',
'AccountServiceSEAS',
'AccountServiceEmail',
'AccountServiceAutoreply',
'AccountServiceLeland',
'AccountServicePTS',
'AccountServiceAFS',
'AccountServiceDialin',
)
# A service can have one of three different statuses
[docs]
@enum.unique
class ServiceStatus(enum.Enum):
"""The possible statuses of a service.
"""
ACTIVE = 'active'
"""The account has and can use this service.
"""
FROZEN = 'frozen'
"""The account has this service, but it is inaccessible right now.
.. note::
Not every service uses this status.
"""
INACTIVE = 'inactive'
"""The account does not have this service.
.. note::
The account may have had the service in the past, but does not now. For
example, full SUNetIDs have access to the autoreply service, but when
autoreply is not enabled, the service is inactive.
"""
# Each account service has a different class. Combine that with the base
# class, and you've got enough stuff to put into its own file!
# Start with the base class, and then move on to the subclasses.
# Classes appear in this file in `__all__` order.
[docs]
@dataclasses.dataclass(frozen=True)
class AccountService(abc.ABC):
"""The base class for all account services.
This is a base class representing a service, and stores the common
properties that a service can have (the :data:`~AccountService.name` of the
service, and its :data:`~AccountService.status`). For service-specific
properties, check out the documentation for the appropriate subclasses.
"""
name: str
"""
The name of the service.
"""
status: ServiceStatus
"""
The service's status.
"""
@property
def is_active(self) -> bool:
"""
``True`` if the service is active.
.. danger::
This will return `True` only when the service is active, *not when
the service is frozen*.
For some services, you might not care if the service is frozen. For
example, if an account's kerberos service is frozen, you might want
to keep them in the directory, even if you don't want to allow
logins.
"""
return (True if self.status == ServiceStatus.ACTIVE else False)
@property
def not_inactive(self) -> bool:
"""
``True`` if the service is not inactive.
This will return `True` when the service is active or frozen. If the
service is inactive, it will return `False`.
"""
return (True if self.status != ServiceStatus.INACTIVE else False)
@classmethod
@abc.abstractmethod
def _from_json(
cls,
source: dict[str, str | list[dict[str, str]]],
):
"""Instantiate a service using a JSON-sourced dict.
This class method must be implemented by each service's own subclass.
The class method is responsible for taking a JSON-sourced dict,
extracting the items specific to the service, and then calling the
class constructor ``cls``.
To help subclasses do their work, this class provides two static
methods:
* :meth:`_json_to_dict` takes the JSON-sourced dict, and extracts the
attributes common to every service. It's a good way to start
building the dict of keyword arguments that will be passed through to
the class constructor.
* :meth:`_get_settings` is used by services that have a ``settings``
dict in their JSON. It supports mandatory & optional settings, and
single- & multi-valued settings. Not all services use this.
"""
...
@staticmethod
def _json_to_dict(
source: dict[str, str | list[dict[str, str]]],
) -> dict[str, Any]:
"""Given a dict parsed from JSON, extract all of the service keys we
recognize.
This is used when we are building a new instance, and only pulls out
the attributes that every service has (the service name, and service
status).
This convenience static method is used by all of the services. The
most-simple services use this method to pull common attributes from the
JSON dict, and then immediately pass that to the class
constructor. Larger services start with this method's returned dict,
and then either add dict items directly, or using `_get_settings` to
extract items from the JSON dict.
"""
return {
'name': source['name'],
'status': ServiceStatus(source['status']),
}
@staticmethod
def _get_setting(
settings: list[dict[str, str]],
target: str
) -> None | str | set[str]:
"""Convenience method to pull a setting out of a settings dict.
:returns: If the setting was single-valued, return it as a string. If the setting was multi-valued, return a set of strings. If the setting was not found, return None.
"""
results = set()
for setting in settings:
if setting['name'] == target:
results.add(setting['value'])
if len(results) == 0:
return None
elif len(results) == 1:
return results.pop()
else:
return results
OptionalTupleOfStrings = Union[tuple[()], tuple[str, ...]]
@staticmethod
def _get_settings(
source: dict[str, str | list[dict[str, str]]],
service: str,
required_keys_single: OptionalTupleOfStrings = tuple(),
required_keys_multiple: OptionalTupleOfStrings = tuple(),
optional_keys_single: OptionalTupleOfStrings = tuple(),
optional_keys_multiple: OptionalTupleOfStrings = tuple(),
) -> dict[str, None | str | set[str]]:
"""Convenience method to pull settings out of a settings dict
:param source: The dict for a single service, taken from the JSON array of services returned by the Account API.
:param service: The name of the service, used when raising exceptions.
:param required_keys_single: The single-valued fields which must be present in an active service.
:param required_keys_multiple: The multi-valued fields which must be present in an active service.
:param optional_keys_single: The single-valued fields which *may* be present in an active service.
:param optional_keys_multiple: The multi-valued fields which *may* be present in an active service.
:returns: A dict, suitable for passing to the dataclass constructor (once ``name`` and ``is_active`` are included).
:raises KeyError: A required setting is missing.
:raises TypeError: The 'settings' list is not actually a list.
"""
result: dict[str, None | str | set[str]] = dict()
# Is this account inactive? If yes, all keys are optional.
if source['status'] != 'active':
optional_keys_single += required_keys_single
optional_keys_multiple += required_keys_multiple
required_keys_single = tuple()
required_keys_multiple = tuple()
# Make sure our settings list is a list.
# (Needed because we can't be sure with the type-checker.)
if isinstance(source['settings'], list):
settings = source['settings']
else:
raise TypeError(f"Service {service} has a non-list 'settings'")
# Add all keys, required and optional
for k in (
required_keys_single + required_keys_multiple +
optional_keys_single + optional_keys_multiple
):
result[k.lower()] = AccountService._get_setting(settings, k)
# Error out if a required key is missing
for k in (required_keys_single + required_keys_multiple):
if result[k.lower()] is None:
raise KeyError(f"Service {service} missing required setting {k}")
# Ensure multi-value keys are in sets
for k in (required_keys_multiple + optional_keys_multiple):
should_be_set = result[k.lower()]
if isinstance(should_be_set, str):
result[k.lower()] = set([should_be_set])
# All done!
return result
[docs]
@dataclasses.dataclass(frozen=True)
class AccountServiceKerberos(AccountService):
"""``kerberos`` service for an Account.
This represents an account's entry in Kerberos. If an account has this
service, then the account is at least a base (or base-sponsored) account.
"""
principal: str
"""
The name of the Kerberos principal. This is normally the same as the
SUNetID.
.. note::
This is an un-scoped principal. In other words, it does not contain a
Kerberos realm (because Stanford has multiple Kerberos realms!).
"""
uid: int
"""
The UNIX UID number.
.. note::
This is used as the account's ``uidNumber`` in LDAP.
"""
@classmethod
def _from_json(
cls,
source: dict[str, str | list[dict[str, str]]],
) -> AccountServiceKerberos:
# Start by pulling out the common stuff
kwargs = cls._json_to_dict(source)
# Add service-specific settings.
kwargs.update(cls._get_settings(
source = source,
service = 'kerberos',
required_keys_single = ('principal',),
optional_keys_single = ('uid',),
))
# Convert uid to an int
if kwargs['uid'] is not None:
kwargs['uid'] = int(kwargs['uid'])
# Call the constructor and return!
return cls(**kwargs)
[docs]
@dataclasses.dataclass(frozen=True)
class AccountServiceLibrary(AccountService):
"""``library`` service for an Account.
`New as of 2019`_, this represents access to `Library e-resources`_. It
has no known settings, and you should *not* assume that having this enabled
means the account is full or full-sponsored.
.. _New as of 2019: https://uit.stanford.edu/service/sponsorship/sponsor-library-e-resources
.. _Library e-resources: https://uit.stanford.edu/service/sponsorship/sponsor-library-e-resources
"""
@classmethod
def _from_json(
cls,
source: dict[str, str | list[dict[str, str]]],
) -> AccountServiceLibrary:
# We don't have any settings. Pull out common stuff and return.
kwargs = cls._json_to_dict(source)
return cls(**kwargs)
[docs]
@dataclasses.dataclass(frozen=True)
class AccountServiceSEAS(AccountService):
"""``seas`` service for an Account.
The "Stanford Electronic Alias Service". If this service is active, then
the account has an associated *@stanford.edu* email address, even if they
don't have a Stanford email box.
.. note::
Even though they include a shared mailbox, `Shared Email`_ functional
accounts have ``seas`` service, but not ``email`` service.
.. _Shared Email: https://uit.stanford.edu/service/sharedemail
"""
local: str | None
"""
This is an optional setting. If the account has a Stanford email box,
*and* the account wants emails delivered to that mailbox, then this is the
this is the canonical email address for that mailbox.
.. warning::
Do not try to send emails directly to this email address.
"""
sunetid: list[str]
"""
This is a setting which may appear multiple times. Each entry represents
an `@stanford.edu` email address. There will always be one entry matching
the account's ID (so that ``id@stanford.edu`` works). If the user has any
email alises, each alias will appear as an additional entry.
.. danger::
Do not use this to look up an account's SUNetID/uid!
"""
sunetidpreferred: str
"""
One of the entries from :data:`~AccountServiceSEAS.sunetid`, this is the
alias the the person prefers others use for email comunication.
.. note::
This setting, along with the tier-specific suffix (``@stanford.edu`` in
PROD), is used for the user's ``mail`` attribute in LDAP (which you can
find in the `people tree`_).
.. _people tree: https://uit.stanford.edu/service/directory/datadefs/people
"""
emailsystem: str | None
"""
For accounts which have a Stanford electronic mailbox (the ``email``
service, this specifies which service hosts said mailbox. Known values
include ``office365`` and ``gmail``.
.. warning::
Functional accounts (including those for Shared Email) do not have the
``email`` service, and so they will not have this setting.
"""
forward: str | None
"""
This is an optional setting. If present, emails received by this account
will be forwarded to the emails listed in this setting. Multiple emails
are separated by a comma.
.. warning::
Do not try to send emails directly to this email address.
"""
urirouteto: str
"""
When a user points their web browser to to `<https://stanford.edu/~id>`_,
this is the URI where the client will be redirected. If it is just a path
(not a full URL), it is relative to `<https://web.stanford.edu/>`_.
"""
@classmethod
def _from_json(
cls,
source: dict[str, str | list[dict[str, str]]],
) -> AccountServiceSEAS:
# Start by pulling out the common stuff
kwargs = cls._json_to_dict(source)
# Add service-specific settings.
kwargs.update(cls._get_settings(
source = source,
service = 'seas',
required_keys_single = ('sunetidpreferred',),
required_keys_multiple = ('sunetid',),
optional_keys_single = ('local', 'emailSystem', 'forward', 'urirouteto'),
))
# Call the constructor and return!
return cls(**kwargs)
[docs]
@dataclasses.dataclass(frozen=True)
class AccountServiceEmail(AccountService):
"""``email`` service for an Account.
If active, the account has a Stanford electronic mailbox. The ``seas``
service should also be present and active.
.. note::
The specific email backend (Zimbra, Office 365, Google, …) is not
indicated.
"""
accounttype: str
"""
For people, this is ``personal``. It *should not be used*.
"""
quota: int | None
"""
This setting was specific to the Zimbra backend, and *should not be used*.
It may disappear in the future.
"""
admin: str | None
"""
This setting is obsolete, and *should not be used*. It may disappear in
the future.
"""
@classmethod
def _from_json(
cls,
source: dict[str, str | list[dict[str, str]]],
) -> AccountServiceEmail:
# Start by pulling out the common stuff
kwargs = cls._json_to_dict(source)
# Add service-specific settings.
kwargs.update(cls._get_settings(
source = source,
service = 'email',
required_keys_single = ('accounttype',),
optional_keys_single = ('quota', 'admin'),
))
# Convert quota to an int
if kwargs['quota'] is not None:
kwargs['quota'] = int(kwargs['quota'])
# Call the constructor and return!
return cls(**kwargs)
[docs]
@dataclasses.dataclass(frozen=True)
class AccountServiceAutoreply(AccountService):
"""``autoreply`` service for an Account.
This **used to** represent the email autoresponder service. If active, incoming
emails would have been forwarded to the autoresponder service, which would send an
appropriate reply.
In May 2025, the central autoresponder service `was turned off
<https://uit.stanford.edu/news/stanford-accounts-getting-new-look>`_.
Autoreply is now managed within the user's email system (Office 365, GMail,
etc.). This service may disappear in the future.
"""
forward: str
"""
The account's canonical email address in the autoresponder system.
.. warning::
Do not try to send emails directly to this email address.
"""
subj: str
"""
The subject line for the response. The string ``$SUBJECT``, if present,
will be replaced with the subject line from the incoming email.
"""
msg: str
"""
The contents of the response. The string ``$SUBJECT``, if present, will be
replaced with the subject line from the incoming email. Also, the strings
``\\r`` and ``\\n`` represent a carriage return and line feed character,
respectively.
"""
@classmethod
def _from_json(
cls,
source: dict[str, str | list[dict[str, str]]],
) -> AccountServiceAutoreply:
# Start by pulling out the common stuff
kwargs = cls._json_to_dict(source)
# Add service-specific settings.
kwargs.update(cls._get_settings(
source = source,
service = 'autoreply',
required_keys_single = ('forward', 'subj', 'msg'),
))
# Call the constructor and return!
return cls(**kwargs)
[docs]
@dataclasses.dataclass(frozen=True)
class AccountServiceLeland(AccountService):
"""``leland`` service for an Account.
This represents the Stanford `Shared Computing`_ environment, once
known as `Leland`_ and known today as `FarmShare`_. If active, users are
able to log in to FarmShare.
.. note::
Full and full-sponsored accounts have this active; base and
base-sponsored account do not. Functional accounts never have this
active.
.. _Shared Computing: https://uit.stanford.edu/service/sharedcomputing
.. _Leland: http://web.archive.org/web/20030728213702/http://lelandsystems.stanford.edu/
.. _FarmShare: https://farmshare.stanford.edu/
"""
shell: str | None
"""
The absolute path to the user's login shell.
.. warning::
Active accounts that use the default shell do not have this set. The
default shell was originally ``/bin/tcsh``, but changed to
``/bin/bash``.
"""
@classmethod
def _from_json(
cls,
source: dict[str, str | list[dict[str, str]]],
) -> AccountServiceLeland:
# Start by pulling out the common stuff
kwargs = cls._json_to_dict(source)
# Add service-specific settings.
kwargs.update(cls._get_settings(
source = source,
service = 'leland',
optional_keys_single = ('shell',),
))
# Call the constructor and return!
return cls(**kwargs)
[docs]
@dataclasses.dataclass(frozen=True)
class AccountServicePTS(AccountService):
"""``pts`` service for an Account.
This represents an account's entry in the AFS Protection Server's database.
An account must have an entry here in order to access *any* AFS services.
"""
uid: int
"""
The UID number for the account in PTS. It should be the same as the
account's UID number in Kerberos.
"""
@classmethod
def _from_json(
cls,
source: dict[str, str | list[dict[str, str]]],
) -> AccountServicePTS:
# Start by pulling out the common stuff
kwargs = cls._json_to_dict(source)
# Add service-specific settings.
kwargs.update(cls._get_settings(
source = source,
service = 'pts',
required_keys_single = ('uid',),
))
# Convert uid to an int
if kwargs['uid'] is not None:
kwargs['uid'] = int(kwargs['uid'])
# Call the constructor and return!
return cls(**kwargs)
[docs]
@dataclasses.dataclass(frozen=True)
class AccountServiceAFS(AccountService):
"""``afs`` service for an Account.
This represents an account's AFS home volume.
.. note::
Just because someone has active AFS service, does not mean they actually
have a home volume. New Faculty and Staff members must `request an AFS
home volume`_.
.. tip::
It is still possible to use AFS without a home volume, as long as you
use a service (like FarmShare) that does not use AFS for home
directories.
.. _request an AFS home volume: https://uit.stanford.edu/service/afs/intro
"""
homedirectory: str
"""
The path to the account's home directory.
.. note::
This is used as the account's ``homeDirectory`` in LDAP. As such, you
will probably want to override it.
.. note::
This setting assumes that AFS is mounted at path ``/afs`` on a system.
This is normally, but not always, the case. This setting also assumes
that your system has an up-to-date copy of the
`CellServDB <https://docs.openafs.org/Reference/5/CellServDB.html>`_
file, which should be the case if you are using a packaged OpenAFS
client.
"""
@classmethod
def _from_json(
cls,
source: dict[str, str | list[dict[str, str]]],
) -> AccountServiceAFS:
# Start by pulling out the common stuff
kwargs = cls._json_to_dict(source)
# Add service-specific settings.
kwargs.update(cls._get_settings(
source = source,
service = 'afs',
required_keys_single = ('homedirectory',),
))
# Call the constructor and return!
return cls(**kwargs)
[docs]
@dataclasses.dataclass(frozen=True)
class AccountServiceDialin(AccountService):
"""``dialin`` service for an Account.
This represented the Stanford UIT dial-in modem pools, which had various
phone numbers, including +1 (415) `498-1440`_ (this was before `area code
650`_) and +1 (650) `325-1010`_. If this service was active, you had
access to the pool. It has no settings, and *should not be used*. If may
disappear in the future.
.. _498-1440: https://web.archive.org/web/19970205070531/http://commserv.stanford.edu/144.html
.. _area code 650: https://web.archive.org/web/19970205070311/http://commserv.stanford.edu/CS.commnews.html
.. _325-1010: https://web.archive.org/web/20000818114817/http://commserv.stanford.edu/modem/
"""
@classmethod
def _from_json(
cls,
source: dict[str, str | list[dict[str, str]]],
) -> AccountServiceDialin:
# We don't have any settings. Pull out common stuff and return.
kwargs = cls._json_to_dict(source)
return cls(**kwargs)