From f364fd52b6546f39487e5d910a0b7547fc60b9c8 Mon Sep 17 00:00:00 2001 From: ngjunsiang Date: Fri, 1 Aug 2025 01:06:32 +0000 Subject: [PATCH 01/22] feat: implement initial API routes for events resource --- campus/apps/api/routes/events.py | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 campus/apps/api/routes/events.py diff --git a/campus/apps/api/routes/events.py b/campus/apps/api/routes/events.py new file mode 100644 index 00000000..104a0346 --- /dev/null +++ b/campus/apps/api/routes/events.py @@ -0,0 +1,58 @@ +"""campus.apps.api.routes.events + +API routes for the events resource (abstract endpoints only). +""" + +from flask import Blueprint, Flask + +import campus_yapper + +import campus.common.validation.flask as flask_validation +from campus.apps.campusauth import authenticate_client +from campus.common.errors import api_errors +# from campus.models import event # Event model to be implemented + +bp = Blueprint('events', __name__, url_prefix='/events') +bp.before_request(authenticate_client) + +yapper = campus_yapper.create() + + +def init_app(app: Flask | Blueprint) -> None: + """Initialise event routes with the given Flask app/blueprint.""" + app.register_blueprint(bp) + + +@bp.post('/') +def new_event(*_: str) -> flask_validation.JsonResponse: + """Create a new event occurrence.""" + # Abstract: validate and create event + return {"message": "Not implemented"}, 501 + + +@bp.get('/') +def get_event_details(event_id: str) -> flask_validation.JsonResponse: + """Get details of an event occurrence.""" + # Abstract: fetch event details + return {"message": "Not implemented"}, 501 + + +@bp.patch('/') +def edit_event(event_id: str) -> flask_validation.JsonResponse: + """Edit an event occurrence.""" + # Abstract: update event + return {"message": "Not implemented"}, 501 + + +@bp.delete('/') +def delete_event(event_id: str) -> flask_validation.JsonResponse: + """Delete an event occurrence.""" + # Abstract: delete event + return {"message": "Not implemented"}, 501 + + +@bp.get('/') +def list_events() -> flask_validation.JsonResponse: + """List event occurrences (with optional filters).""" + # Abstract: list events + return {"message": "Not implemented"}, 501 From ef2f14053ae2e376ef3f88a91fcb641d617e0a62 Mon Sep 17 00:00:00 2001 From: sparsetable Date: Sun, 24 Aug 2025 15:53:33 +0000 Subject: [PATCH 02/22] Add schema --- campus/models/event.py | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 campus/models/event.py diff --git a/campus/models/event.py b/campus/models/event.py new file mode 100644 index 00000000..1b89ad8f --- /dev/null +++ b/campus/models/event.py @@ -0,0 +1,51 @@ +"""campus.models.event + +This module provides classes for managing Campus events. + +Data structures: +- + +Main operations: +- CRUD +""" + +from campus.common.utils import uid, utc_time +from campus.common.schema import CampusID +from campus.storage import get_table +from campus.common import devops + +# Create a new EventID with CampusID(uid.generate_category_uid("event", length=8)) +EventID = CampusID + +""" +Database schema: +id: TEXT - EventID, is the primary key +name: TEXT - name of the event +venue: TEXT - venue of the event +time: TEXT - rfc3339 time +length: INTEGER - length of event in seconds +""" + +# TODO: Implement some venue validation through some master list? + +TABLE = "events" + +@devops.block_env(devops.PRODUCTION) +def init_db(): + """Initialize the tables needed by the model. + + This function is intended to be called only in a test environment (using a + local-only db like SQLite), or in a staging environment before upgrading to + production. + """ + storage = get_table(TABLE) + schema = f""" + CREATE TABLE IF NOT EXISTS "{TABLE}" ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + venue TEXT NOT NULL, + time TEXT NOT NULL, + length INTEGER NULL, + ) + """ + storage.init_table(schema) From 5e57a7b8aa488561d187e3d75379a3a2733cebe0 Mon Sep 17 00:00:00 2001 From: sparsetable Date: Sun, 24 Aug 2025 17:22:29 +0000 Subject: [PATCH 03/22] feat: implement events model --- campus/models/event.py | 131 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/campus/models/event.py b/campus/models/event.py index 1b89ad8f..5ae9b8b4 100644 --- a/campus/models/event.py +++ b/campus/models/event.py @@ -9,21 +9,29 @@ - CRUD """ +from typing import NotRequired, TypedDict, Unpack + from campus.common.utils import uid, utc_time +from campus.common.errors import api_errors from campus.common.schema import CampusID from campus.storage import get_table from campus.common import devops +from campus.models.base import BaseRecord # Create a new EventID with CampusID(uid.generate_category_uid("event", length=8)) EventID = CampusID +### Database-related code + """ Database schema: id: TEXT - EventID, is the primary key name: TEXT - name of the event venue: TEXT - venue of the event -time: TEXT - rfc3339 time +time: TIMESTAMPTZ - rfc3339 time length: INTEGER - length of event in seconds + +created_at: TIMESTAMPTZ - from BaseRecord """ # TODO: Implement some venue validation through some master list? @@ -44,8 +52,125 @@ def init_db(): id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, venue TEXT NOT NULL, - time TEXT NOT NULL, - length INTEGER NULL, + time TIMESTAMPTZ NOT NULL, + length INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL ) """ storage.init_table(schema) + +class EventRecord(BaseRecord, total = True): + """The event record stored in the events table.""" + id: EventID + name: str + venue: str + time: utc_time.datetime + length: int # time in seconds + # Also has created_at from BaseRecord. + +### Request body schemas + +# In requests that modify events (new, update), allow str | utc_time.datetime for time. +# Everywhere else, it is ONLY utc_time.datetime. + +class RequestEventInfo(TypedDict): + """Request body schema for a request with event info as parameters.""" + name: str + venue: str + time: str | utc_time.datetime # rfc3339 time if str + length: int # time in seconds + +class EventNew(RequestEventInfo, total=True): + """Request body schema for a events.new operation.""" + pass + +# total = False as all params are optional. +class EventUpdate(RequestEventInfo, total=False): + """Request body schema for a events.update operation.""" + pass + +# events.delete and events.get do not need a request body schema as it takes no params. + +def coerce_time_datetime(time: str | utc_time.datetime) -> utc_time.datetime: + """Coerces time from str | utc_time.datetime to utc_time.datetime""" + if isinstance(time, utc_time.datetime): + return time + else: + return utc_time.from_rfc3339(time) + +### Response body schemas + +# Response body schema representing the result of a events.get, events.update and events.new operation. +EventResource = EventRecord + +### Model classes. + +class Event: + """Event model for handling database operations related to events.""" + + def __init__(self): + """Initialize the User model with a table storage interface.""" + self.storage = get_table(TABLE) + + def new(self, **fields: Unpack[EventNew]) -> EventResource: + """Create a new event.""" + event_id = EventID(uid.generate_category_uid("event", length=8)) + + # Least ugly solution due to type gymnastics. + record = EventRecord( + id=event_id, + created_at=utc_time.now(), + name=fields["name"], + venue=fields["venue"], + length=fields["length"], + time=coerce_time_datetime(fields["time"]) + ) + + try: + self.storage.insert_one(dict(record)) + return EventResource(**record) + except Exception as e: + raise api_errors.InternalError(message=str(e), error=e) + + def _try_get(self, event_id: EventID) -> EventRecord: + """Tries to get event by event_id + Raises InternalError upon other errors.""" + try: + event = self.storage.get_by_id(event_id) + if not event: + raise api_errors.ConflictError( + message="Event not found", + event_id=event_id + ) + event = EventRecord(**event) # Assert-coerce to EventResource. + except api_errors.ConflictError: + raise + except Exception as e: + raise api_errors.InternalError(message=str(e), error=e) + + return event + + def delete(self, event_id: EventID) -> None: + """Delete an event by id.""" + self._try_get(event_id) # Make sure it exists. + try: + self.storage.delete_by_id(event_id) + except Exception as e: + raise api_errors.InternalError(message=str(e), error=e) + + def get(self, event_id: EventID) -> EventResource: + """Get an event by id.""" + return self._try_get(event_id) + + def update(self, event_id: EventID, **updates: Unpack[EventUpdate]) -> None: + """Update an event by id.""" + # Check if user exists first + + if "time" in updates: + updates["time"] = coerce_time_datetime(updates["time"]) + + self._try_get(event_id) # Make sure it exists. + try: + self.storage.update_by_id(event_id, dict(updates)) + except Exception as e: + raise api_errors.InternalError(message=str(e), error=e) \ No newline at end of file From fa2db0e82ff2c9ae80744beab0cd0ff714ae3584 Mon Sep 17 00:00:00 2001 From: sparsetable Date: Sun, 24 Aug 2025 17:26:12 +0000 Subject: [PATCH 04/22] feat: add to main init. --- campus/models/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/campus/models/__init__.py b/campus/models/__init__.py index 1c8c1538..9b077f3a 100644 --- a/campus/models/__init__.py +++ b/campus/models/__init__.py @@ -44,7 +44,7 @@ - For ease of lookup generalisation, all models must use a consistent ID pattern. -- The design of the ID patter should not depend on features of a specific +- The design of the ID pattern should not depend on features of a specific database or database type. ### Mirror Campus API operations @@ -98,11 +98,13 @@ from . import ( circle, emailotp, - user + user, + event ) __all__ = [ "circle", "emailotp", - "user" + "user", + "event" ] From bfd4802b81b31d4a35c28171e8be2e5dd91d91c4 Mon Sep 17 00:00:00 2001 From: sparsetable Date: Sun, 24 Aug 2025 17:52:24 +0000 Subject: [PATCH 05/22] Cleanly handle datetime <-> str --- campus/apps/api/routes/__init__.py | 3 ++- campus/apps/api/routes/events.py | 4 +++- campus/models/event.py | 23 ++++++----------------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/campus/apps/api/routes/__init__.py b/campus/apps/api/routes/__init__.py index 6f5ac837..eee83dba 100644 --- a/campus/apps/api/routes/__init__.py +++ b/campus/apps/api/routes/__init__.py @@ -3,11 +3,12 @@ This is a namespace module for the Campus API routes. """ -from . import circles, emailotp, users, admin +from . import circles, emailotp, users, admin, events __all__ = [ "circles", "emailotp", "users", "admin", + "events" ] diff --git a/campus/apps/api/routes/events.py b/campus/apps/api/routes/events.py index 104a0346..1a4e37ad 100644 --- a/campus/apps/api/routes/events.py +++ b/campus/apps/api/routes/events.py @@ -10,7 +10,9 @@ import campus.common.validation.flask as flask_validation from campus.apps.campusauth import authenticate_client from campus.common.errors import api_errors -# from campus.models import event # Event model to be implemented +from campus.models import event # Event model to be implemented + +# NOTE: AS THE MODEL ONLY DEALS IN DATETIMES, ALL STR <-> DATETIME CONVERSION IS HANDLED HERE! bp = Blueprint('events', __name__, url_prefix='/events') bp.before_request(authenticate_client) diff --git a/campus/models/event.py b/campus/models/event.py index 5ae9b8b4..6def8071 100644 --- a/campus/models/event.py +++ b/campus/models/event.py @@ -28,7 +28,7 @@ id: TEXT - EventID, is the primary key name: TEXT - name of the event venue: TEXT - venue of the event -time: TIMESTAMPTZ - rfc3339 time +event_time: TIMESTAMPTZ - time that event starts. length: INTEGER - length of event in seconds created_at: TIMESTAMPTZ - from BaseRecord @@ -52,7 +52,7 @@ def init_db(): id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, venue TEXT NOT NULL, - time TIMESTAMPTZ NOT NULL, + event_time TIMESTAMPTZ NOT NULL, length INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL ) @@ -64,20 +64,19 @@ class EventRecord(BaseRecord, total = True): id: EventID name: str venue: str - time: utc_time.datetime + event_time: utc_time.datetime length: int # time in seconds # Also has created_at from BaseRecord. ### Request body schemas -# In requests that modify events (new, update), allow str | utc_time.datetime for time. -# Everywhere else, it is ONLY utc_time.datetime. +# NOTE: THIS MODEL DEALS ONLY WITH DATETIMES. THE FLASK FILE WILL DEAL WITH CONVERSIONS FROM STRING. class RequestEventInfo(TypedDict): """Request body schema for a request with event info as parameters.""" name: str venue: str - time: str | utc_time.datetime # rfc3339 time if str + event_time: utc_time.datetime # rfc3339 time if str length: int # time in seconds class EventNew(RequestEventInfo, total=True): @@ -91,13 +90,6 @@ class EventUpdate(RequestEventInfo, total=False): # events.delete and events.get do not need a request body schema as it takes no params. -def coerce_time_datetime(time: str | utc_time.datetime) -> utc_time.datetime: - """Coerces time from str | utc_time.datetime to utc_time.datetime""" - if isinstance(time, utc_time.datetime): - return time - else: - return utc_time.from_rfc3339(time) - ### Response body schemas # Response body schema representing the result of a events.get, events.update and events.new operation. @@ -123,7 +115,7 @@ def new(self, **fields: Unpack[EventNew]) -> EventResource: name=fields["name"], venue=fields["venue"], length=fields["length"], - time=coerce_time_datetime(fields["time"]) + event_time=fields["event_time"] ) try: @@ -166,9 +158,6 @@ def update(self, event_id: EventID, **updates: Unpack[EventUpdate]) -> None: """Update an event by id.""" # Check if user exists first - if "time" in updates: - updates["time"] = coerce_time_datetime(updates["time"]) - self._try_get(event_id) # Make sure it exists. try: self.storage.update_by_id(event_id, dict(updates)) From be393422a863522b9a3dc2cf8b76488e5af28fc2 Mon Sep 17 00:00:00 2001 From: sparsetable Date: Sun, 24 Aug 2025 18:20:48 +0000 Subject: [PATCH 06/22] Flask datetime conversion proposal --- campus/apps/api/routes/events.py | 56 ++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/campus/apps/api/routes/events.py b/campus/apps/api/routes/events.py index 1a4e37ad..025c6b46 100644 --- a/campus/apps/api/routes/events.py +++ b/campus/apps/api/routes/events.py @@ -3,7 +3,10 @@ API routes for the events resource (abstract endpoints only). """ -from flask import Blueprint, Flask +from typing import Callable, Any + +from flask import Blueprint, Flask, +from flask import request as flask_request # For datetime wrapper import campus_yapper @@ -11,25 +14,64 @@ from campus.apps.campusauth import authenticate_client from campus.common.errors import api_errors from campus.models import event # Event model to be implemented - -# NOTE: AS THE MODEL ONLY DEALS IN DATETIMES, ALL STR <-> DATETIME CONVERSION IS HANDLED HERE! +from campus.common.utils import utc_time bp = Blueprint('events', __name__, url_prefix='/events') bp.before_request(authenticate_client) yapper = campus_yapper.create() - +events = event.Event() def init_app(app: Flask | Blueprint) -> None: """Initialise event routes with the given Flask app/blueprint.""" app.register_blueprint(bp) +# NOTE: AS THE MODEL ONLY DEALS IN DATETIMES, ALL STR <-> DATETIME CONVERSION IS HANDLED HERE! +# TODO: Is this an antipattern? + +DATETIME_PARAMS = ["event_time"] +DATETIME_RETURNS = ["event_time", "created_at"] +# Datetime returns can be inferred from type. +def datetime_wrapper(func: Callable) -> Callable: + """Wraps a flask route to transform input datetimes to python datetimes + and output datetimes to rfc3339 strings.""" + def datetime_wrapped(*args, **kwargs): + """Wrapped function produced by datetime_wrapper.""" + raw_payload = flask_request.get_raw_json(on_error=api_errors.raise_api_error) + + # Request body str -> datetime + for param in DATETIME_PARAMS: + if param in raw_payload: + raw_payload[param] = utc_time.from_rfc3339(raw_payload[param]) + + data, status_code = func(*args, **kwargs) + + # Reply datetime -> str + for param in DATETIME_RETURNS: + if param in data: + data[param] = utc_time.to_rfc3339(data[param]) + + return data, status_code + return datetime_wrapped @bp.post('/') +@datetime_wrapper def new_event(*_: str) -> flask_validation.JsonResponse: - """Create a new event occurrence.""" - # Abstract: validate and create event - return {"message": "Not implemented"}, 501 + """Create a new event.""" + + payload = flask_validation.validate_request_and_extract_json( + event.EventNew.__annotations__, + on_error=api_errors.raise_api_error, + ) + resource = events.new(**payload) + flask_validation.validate_json_response( + event.EventResource.__annotations__, + resource, + on_error=api_errors.raise_api_error, + ) + + yapper.emit('campus.events.new') + return dict(resource), 201 @bp.get('/') From cf5d779e0dc8db7da70a8bb9f21be804d8ee63a6 Mon Sep 17 00:00:00 2001 From: sparsetable Date: Sun, 24 Aug 2025 18:31:46 +0000 Subject: [PATCH 07/22] A sane proposal --- campus/apps/api/routes/events.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/campus/apps/api/routes/events.py b/campus/apps/api/routes/events.py index 025c6b46..b63101c7 100644 --- a/campus/apps/api/routes/events.py +++ b/campus/apps/api/routes/events.py @@ -27,7 +27,8 @@ def init_app(app: Flask | Blueprint) -> None: app.register_blueprint(bp) # NOTE: AS THE MODEL ONLY DEALS IN DATETIMES, ALL STR <-> DATETIME CONVERSION IS HANDLED HERE! -# TODO: Is this an antipattern? +# TODO: This is probably an antipattern. The best solution would be for validate_request_and_extract_json and validate_json_response +# to do the type conversion, as it already has the templates anyway. DATETIME_PARAMS = ["event_time"] DATETIME_RETURNS = ["event_time", "created_at"] From 6994704a759bee7c6d0e8e166c09e777e77e0042 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 25 Aug 2025 08:59:16 +0000 Subject: [PATCH 08/22] refacotr: schema edits after discussion --- campus/models/event.py | 90 ++++++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/campus/models/event.py b/campus/models/event.py index 6def8071..1eb5cac8 100644 --- a/campus/models/event.py +++ b/campus/models/event.py @@ -21,23 +21,25 @@ # Create a new EventID with CampusID(uid.generate_category_uid("event", length=8)) EventID = CampusID -### Database-related code +# Database-related code """ Database schema: id: TEXT - EventID, is the primary key +created_at: TEXT - from BaseRecord name: TEXT - name of the event -venue: TEXT - venue of the event -event_time: TIMESTAMPTZ - time that event starts. -length: INTEGER - length of event in seconds - -created_at: TIMESTAMPTZ - from BaseRecord +description: TEXT longer description or details of event +location: TEXT - location of the event +location_url: TEXT - URL for the location of the event +start_time: TEXT - time that event starts. +duration: INTEGER - duration of event in seconds """ -# TODO: Implement some venue validation through some master list? +# TODO: Implement some location validation through some master list? TABLE = "events" + @devops.block_env(devops.PRODUCTION) def init_db(): """Initialize the tables needed by the model. @@ -49,53 +51,62 @@ def init_db(): storage = get_table(TABLE) schema = f""" CREATE TABLE IF NOT EXISTS "{TABLE}" ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - venue TEXT NOT NULL, - event_time TIMESTAMPTZ NOT NULL, - length INTEGER NOT NULL, - created_at TIMESTAMPTZ NOT NULL + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + name TEXT, + location TEXT, + start_time TEXT, + duration INTEGER ) """ storage.init_table(schema) -class EventRecord(BaseRecord, total = True): + +class EventRecord(BaseRecord, total=True): """The event record stored in the events table.""" id: EventID name: str - venue: str - event_time: utc_time.datetime - length: int # time in seconds + location: str + location_url: str + start_time: str # rfc3339 string + duration: int # time in minutes # Also has created_at from BaseRecord. -### Request body schemas +# Request body schemas # NOTE: THIS MODEL DEALS ONLY WITH DATETIMES. THE FLASK FILE WILL DEAL WITH CONVERSIONS FROM STRING. + class RequestEventInfo(TypedDict): """Request body schema for a request with event info as parameters.""" name: str - venue: str - event_time: utc_time.datetime # rfc3339 time if str - length: int # time in seconds + location: str + location_url: str + start_time: str # rfc3339 string + duration: int # time in minutes -class EventNew(RequestEventInfo, total=True): + +class EventNew(RequestEventInfo, total=True): """Request body schema for a events.new operation.""" pass # total = False as all params are optional. + + class EventUpdate(RequestEventInfo, total=False): """Request body schema for a events.update operation.""" pass # events.delete and events.get do not need a request body schema as it takes no params. -### Response body schemas +# Response body schemas + # Response body schema representing the result of a events.get, events.update and events.new operation. EventResource = EventRecord -### Model classes. +# Model classes. + class Event: """Event model for handling database operations related to events.""" @@ -103,19 +114,20 @@ class Event: def __init__(self): """Initialize the User model with a table storage interface.""" self.storage = get_table(TABLE) - + def new(self, **fields: Unpack[EventNew]) -> EventResource: """Create a new event.""" - event_id = EventID(uid.generate_category_uid("event", length=8)) + event_id = EventID(uid.generate_category_uid("event", length=16)) # Least ugly solution due to type gymnastics. record = EventRecord( id=event_id, - created_at=utc_time.now(), + created_at=utc_time.to_rfc3339(utc_time.now()), name=fields["name"], - venue=fields["venue"], - length=fields["length"], - event_time=fields["event_time"] + location=fields["location"], + location_url=fields["location_url"], + duration=fields["duration"], + start_time=utc_time.to_rfc3339(fields["start_time"]) ) try: @@ -123,7 +135,7 @@ def new(self, **fields: Unpack[EventNew]) -> EventResource: return EventResource(**record) except Exception as e: raise api_errors.InternalError(message=str(e), error=e) - + def _try_get(self, event_id: EventID) -> EventRecord: """Tries to get event by event_id Raises InternalError upon other errors.""" @@ -134,32 +146,32 @@ def _try_get(self, event_id: EventID) -> EventRecord: message="Event not found", event_id=event_id ) - event = EventRecord(**event) # Assert-coerce to EventResource. + event = EventRecord(**event) # Assert-coerce to EventResource. except api_errors.ConflictError: raise except Exception as e: raise api_errors.InternalError(message=str(e), error=e) - + return event - + def delete(self, event_id: EventID) -> None: """Delete an event by id.""" - self._try_get(event_id) # Make sure it exists. + self._try_get(event_id) # Make sure it exists. try: self.storage.delete_by_id(event_id) except Exception as e: raise api_errors.InternalError(message=str(e), error=e) - + def get(self, event_id: EventID) -> EventResource: """Get an event by id.""" return self._try_get(event_id) - + def update(self, event_id: EventID, **updates: Unpack[EventUpdate]) -> None: """Update an event by id.""" # Check if user exists first - self._try_get(event_id) # Make sure it exists. + self._try_get(event_id) # Make sure it exists. try: self.storage.update_by_id(event_id, dict(updates)) except Exception as e: - raise api_errors.InternalError(message=str(e), error=e) \ No newline at end of file + raise api_errors.InternalError(message=str(e), error=e) From 4e3710232d055d3a20a5c13e817549fe6338e51f Mon Sep 17 00:00:00 2001 From: "nyjc.computing" <164971954+nycomp@users.noreply.github.com> Date: Tue, 21 Oct 2025 00:24:26 +0000 Subject: [PATCH 09/22] updates to follow schema.DateTime --- campus/models/event.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/campus/models/event.py b/campus/models/event.py index 1eb5cac8..c6aef5aa 100644 --- a/campus/models/event.py +++ b/campus/models/event.py @@ -9,17 +9,18 @@ - CRUD """ -from typing import NotRequired, TypedDict, Unpack +from typing import TypedDict, Unpack -from campus.common.utils import uid, utc_time +from campus.common import schema +from campus.common.utils import uid from campus.common.errors import api_errors -from campus.common.schema import CampusID -from campus.storage import get_table +# from campus.common.schema import CampusID +from campus.storage import get_table, errors as storage_errors from campus.common import devops from campus.models.base import BaseRecord # Create a new EventID with CampusID(uid.generate_category_uid("event", length=8)) -EventID = CampusID +EventID = schema.CampusID # Database-related code @@ -62,7 +63,9 @@ def init_db(): storage.init_table(schema) -class EventRecord(BaseRecord, total=True): + +# TODO: Update to dataclass (https://docs.python.org/3.11/library/dataclasses.html) +class EventRecord(BaseRecord): """The event record stored in the events table.""" id: EventID name: str @@ -74,8 +77,6 @@ class EventRecord(BaseRecord, total=True): # Request body schemas -# NOTE: THIS MODEL DEALS ONLY WITH DATETIMES. THE FLASK FILE WILL DEAL WITH CONVERSIONS FROM STRING. - class RequestEventInfo(TypedDict): """Request body schema for a request with event info as parameters.""" @@ -115,26 +116,32 @@ def __init__(self): """Initialize the User model with a table storage interface.""" self.storage = get_table(TABLE) - def new(self, **fields: Unpack[EventNew]) -> EventResource: + def new(self, **fields: Unpack[EventNew]) -> EventRecord: """Create a new event.""" event_id = EventID(uid.generate_category_uid("event", length=16)) # Least ugly solution due to type gymnastics. + dtnow = schema.DateTime.utcnow() record = EventRecord( id=event_id, - created_at=utc_time.to_rfc3339(utc_time.now()), + created_at=dtnow, name=fields["name"], location=fields["location"], location_url=fields["location_url"], duration=fields["duration"], - start_time=utc_time.to_rfc3339(fields["start_time"]) + start_time=schema.DateTime(fields["start_time"]) ) try: - self.storage.insert_one(dict(record)) - return EventResource(**record) + self.storage.insert_one(record.to_dict()) + return record + except storage_errors.ConflictError: + raise api_errors.ConflictError( + message="Event with the same ID already exists", + event_id=event_id + ) from None except Exception as e: - raise api_errors.InternalError(message=str(e), error=e) + raise api_errors.InternalError(message=str(e), error=e) from e def _try_get(self, event_id: EventID) -> EventRecord: """Tries to get event by event_id From a7e64bbf470e0dec428002c56291a9bed8edca65 Mon Sep 17 00:00:00 2001 From: "nyjc.computing" <164971954+nycomp@users.noreply.github.com> Date: Tue, 21 Oct 2025 00:28:32 +0000 Subject: [PATCH 10/22] updates to follow new namespace --- campus/apps/api/routes/events.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/campus/apps/api/routes/events.py b/campus/apps/api/routes/events.py index b63101c7..b81a14ff 100644 --- a/campus/apps/api/routes/events.py +++ b/campus/apps/api/routes/events.py @@ -3,18 +3,17 @@ API routes for the events resource (abstract endpoints only). """ -from typing import Callable, Any +from typing import Callable -from flask import Blueprint, Flask, +from flask import Blueprint, Flask from flask import request as flask_request # For datetime wrapper -import campus_yapper - import campus.common.validation.flask as flask_validation from campus.apps.campusauth import authenticate_client from campus.common.errors import api_errors from campus.models import event # Event model to be implemented from campus.common.utils import utc_time +import campus.yapper as campus_yapper bp = Blueprint('events', __name__, url_prefix='/events') bp.before_request(authenticate_client) @@ -67,12 +66,12 @@ def new_event(*_: str) -> flask_validation.JsonResponse: resource = events.new(**payload) flask_validation.validate_json_response( event.EventResource.__annotations__, - resource, + resource.to_dict(), on_error=api_errors.raise_api_error, ) yapper.emit('campus.events.new') - return dict(resource), 201 + return resource.to_dict(), 200 @bp.get('/') From e42a5fc2469ee57c637bb4ba1a9c719b892b1dbe Mon Sep 17 00:00:00 2001 From: sparsetable Date: Tue, 11 Nov 2025 14:28:29 +0000 Subject: [PATCH 11/22] Update model to use dataclasses --- campus/models/event.py | 129 ++++++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 61 deletions(-) diff --git a/campus/models/event.py b/campus/models/event.py index c6aef5aa..9a3b3afe 100644 --- a/campus/models/event.py +++ b/campus/models/event.py @@ -9,20 +9,18 @@ - CRUD """ -from typing import TypedDict, Unpack +from typing import Unpack, Any, Self, Type +from dataclasses import dataclass, asdict from campus.common import schema from campus.common.utils import uid from campus.common.errors import api_errors -# from campus.common.schema import CampusID +from campus.common.schema import CampusID from campus.storage import get_table, errors as storage_errors from campus.common import devops from campus.models.base import BaseRecord -# Create a new EventID with CampusID(uid.generate_category_uid("event", length=8)) -EventID = schema.CampusID - -# Database-related code +### Database-related code """ Database schema: @@ -36,11 +34,10 @@ duration: INTEGER - duration of event in seconds """ -# TODO: Implement some location validation through some master list? +# TODO: Implement description. TABLE = "events" - @devops.block_env(devops.PRODUCTION) def init_db(): """Initialize the tables needed by the model. @@ -57,57 +54,66 @@ def init_db(): name TEXT, location TEXT, start_time TEXT, + location_url TEXT, duration INTEGER ) """ storage.init_table(schema) - -# TODO: Update to dataclass (https://docs.python.org/3.11/library/dataclasses.html) class EventRecord(BaseRecord): """The event record stored in the events table.""" - id: EventID name: str location: str location_url: str - start_time: str # rfc3339 string - duration: int # time in minutes - # Also has created_at from BaseRecord. - -# Request body schemas - - -class RequestEventInfo(TypedDict): + start_time: schema.DateTime + duration: int + # Also has created_at & ID from BaseRecord. + +### Request body schemas +@dataclass +class BaseRequest: + """A dataclass with to_dict and from_dict, similar to a + BaseRecord.""" + @classmethod + def from_dict(cls: Type[Self], data: dict) -> Self: + """Create a record from a dictionary.""" + return cls(**data) + + def to_dict(self) -> dict[str, Any]: + """Convert the record to a dictionary.""" + return asdict(self) + +@dataclass +class EventGet(BaseRequest): """Request body schema for a request with event info as parameters.""" + id: CampusID + +@dataclass +class EventNew(BaseRequest): + """Request body schema for a events.new operation.""" name: str location: str location_url: str - start_time: str # rfc3339 string - duration: int # time in minutes - - -class EventNew(RequestEventInfo, total=True): - """Request body schema for a events.new operation.""" - pass + start_time: schema.DateTime + duration: int -# total = False as all params are optional. +@dataclass +class EventDelete(BaseRequest): + """Request body schema for a deletion request.""" + id: CampusID - -class EventUpdate(RequestEventInfo, total=False): +@dataclass +class EventUpdate(BaseRequest): """Request body schema for a events.update operation.""" - pass - -# events.delete and events.get do not need a request body schema as it takes no params. - -# Response body schemas - - -# Response body schema representing the result of a events.get, events.update and events.new operation. -EventResource = EventRecord - -# Model classes. + id: CampusID + name: str + location: str + location_url: str + start_time: schema.DateTime + duration: int +### Model classes. class Event: """Event model for handling database operations related to events.""" @@ -116,21 +122,21 @@ def __init__(self): """Initialize the User model with a table storage interface.""" self.storage = get_table(TABLE) - def new(self, **fields: Unpack[EventNew]) -> EventRecord: + def new(self, fields: EventNew) -> EventRecord: """Create a new event.""" - event_id = EventID(uid.generate_category_uid("event", length=16)) + event_id = CampusID(uid.generate_category_uid("event", length=16)) # Least ugly solution due to type gymnastics. dtnow = schema.DateTime.utcnow() - record = EventRecord( - id=event_id, - created_at=dtnow, - name=fields["name"], - location=fields["location"], - location_url=fields["location_url"], - duration=fields["duration"], - start_time=schema.DateTime(fields["start_time"]) - ) + record = EventRecord.from_dict({ + "id": event_id, + "created_at": dtnow, + "name": fields.name, + "location": fields.location, + "location_url": fields.location_url, + "duration": fields.duration, + "start_time": fields.start_time + }) try: self.storage.insert_one(record.to_dict()) @@ -143,8 +149,9 @@ def new(self, **fields: Unpack[EventNew]) -> EventRecord: except Exception as e: raise api_errors.InternalError(message=str(e), error=e) from e - def _try_get(self, event_id: EventID) -> EventRecord: - """Tries to get event by event_id + def _try_get(self, event_id: CampusID) -> EventRecord: + """Private method. + Tries to get event by event_id Raises InternalError upon other errors.""" try: event = self.storage.get_by_id(event_id) @@ -161,24 +168,24 @@ def _try_get(self, event_id: EventID) -> EventRecord: return event - def delete(self, event_id: EventID) -> None: + def delete(self, fields: EventDelete) -> None: """Delete an event by id.""" - self._try_get(event_id) # Make sure it exists. + self._try_get(fields.id) # Make sure it exists. try: - self.storage.delete_by_id(event_id) + self.storage.delete_by_id(fields.id) except Exception as e: raise api_errors.InternalError(message=str(e), error=e) - def get(self, event_id: EventID) -> EventResource: + def get(self, fields: EventGet) -> EventRecord: """Get an event by id.""" - return self._try_get(event_id) + return self._try_get(fields.id) - def update(self, event_id: EventID, **updates: Unpack[EventUpdate]) -> None: + def update(self, fields: EventUpdate) -> None: """Update an event by id.""" # Check if user exists first - self._try_get(event_id) # Make sure it exists. + self._try_get(fields.id) # Make sure it exists. try: - self.storage.update_by_id(event_id, dict(updates)) + self.storage.update_by_id(fields.id, fields.to_dict()) except Exception as e: raise api_errors.InternalError(message=str(e), error=e) From 82bc09830a44c6d49002c766328f96ffa86a6ccf Mon Sep 17 00:00:00 2001 From: sparsetable Date: Tue, 11 Nov 2025 14:44:11 +0000 Subject: [PATCH 12/22] Update flask endpoints --- campus/apps/api/routes/events.py | 96 +++++++++++++++++--------------- campus/models/event.py | 42 +++++++------- 2 files changed, 73 insertions(+), 65 deletions(-) diff --git a/campus/apps/api/routes/events.py b/campus/apps/api/routes/events.py index b81a14ff..6d67d502 100644 --- a/campus/apps/api/routes/events.py +++ b/campus/apps/api/routes/events.py @@ -25,37 +25,7 @@ def init_app(app: Flask | Blueprint) -> None: """Initialise event routes with the given Flask app/blueprint.""" app.register_blueprint(bp) -# NOTE: AS THE MODEL ONLY DEALS IN DATETIMES, ALL STR <-> DATETIME CONVERSION IS HANDLED HERE! -# TODO: This is probably an antipattern. The best solution would be for validate_request_and_extract_json and validate_json_response -# to do the type conversion, as it already has the templates anyway. - -DATETIME_PARAMS = ["event_time"] -DATETIME_RETURNS = ["event_time", "created_at"] -# Datetime returns can be inferred from type. -def datetime_wrapper(func: Callable) -> Callable: - """Wraps a flask route to transform input datetimes to python datetimes - and output datetimes to rfc3339 strings.""" - def datetime_wrapped(*args, **kwargs): - """Wrapped function produced by datetime_wrapper.""" - raw_payload = flask_request.get_raw_json(on_error=api_errors.raise_api_error) - - # Request body str -> datetime - for param in DATETIME_PARAMS: - if param in raw_payload: - raw_payload[param] = utc_time.from_rfc3339(raw_payload[param]) - - data, status_code = func(*args, **kwargs) - - # Reply datetime -> str - for param in DATETIME_RETURNS: - if param in data: - data[param] = utc_time.to_rfc3339(data[param]) - - return data, status_code - return datetime_wrapped - @bp.post('/') -@datetime_wrapper def new_event(*_: str) -> flask_validation.JsonResponse: """Create a new event.""" @@ -63,40 +33,74 @@ def new_event(*_: str) -> flask_validation.JsonResponse: event.EventNew.__annotations__, on_error=api_errors.raise_api_error, ) - resource = events.new(**payload) + + record = events.new(event.EventNew.from_dict(payload)) + flask_validation.validate_json_response( - event.EventResource.__annotations__, - resource.to_dict(), + event.EventRecord.__annotations__, + record.to_dict(), on_error=api_errors.raise_api_error, ) yapper.emit('campus.events.new') - return resource.to_dict(), 200 + return record.to_dict(), 200 @bp.get('/') def get_event_details(event_id: str) -> flask_validation.JsonResponse: """Get details of an event occurrence.""" - # Abstract: fetch event details - return {"message": "Not implemented"}, 501 + + # Parse payload even though empty expected. + # To allow for future additions. + payload = flask_validation.validate_request_and_extract_json( + event.EventGet.__annotations__, + on_error=api_errors.raise_api_error, + ) + + record = events.get(event_id, event.EventGet.from_dict(payload)) + + flask_validation.validate_json_response( + event.EventRecord.__annotations__, + record.to_dict(), + on_error=api_errors.raise_api_error, + ) + + # No event emitted. + return record.to_dict(), 200 @bp.patch('/') -def edit_event(event_id: str) -> flask_validation.JsonResponse: +def update_event(event_id: str) -> flask_validation.JsonResponse: """Edit an event occurrence.""" - # Abstract: update event - return {"message": "Not implemented"}, 501 + + payload = flask_validation.validate_request_and_extract_json( + event.EventUpdate.__annotations__, + on_error=api_errors.raise_api_error, + ) + + record = events.update(event_id, event.EventUpdate.from_dict(payload)) + + flask_validation.validate_json_response( + event.EventRecord.__annotations__, + record.to_dict(), + on_error=api_errors.raise_api_error, + ) + + yapper.emit('campus.events.update') + return record.to_dict(), 200 @bp.delete('/') def delete_event(event_id: str) -> flask_validation.JsonResponse: """Delete an event occurrence.""" - # Abstract: delete event - return {"message": "Not implemented"}, 501 + # Parse payload even though empty expected. + # To allow for future additions. + payload = flask_validation.validate_request_and_extract_json( + event.EventDelete.__annotations__, + on_error=api_errors.raise_api_error, + ) + events.delete(event_id, event.EventDelete.from_dict(payload)) -@bp.get('/') -def list_events() -> flask_validation.JsonResponse: - """List event occurrences (with optional filters).""" - # Abstract: list events - return {"message": "Not implemented"}, 501 + yapper.emit('campus.events.delete') + return {}, 200 \ No newline at end of file diff --git a/campus/models/event.py b/campus/models/event.py index 9a3b3afe..c16cc45d 100644 --- a/campus/models/event.py +++ b/campus/models/event.py @@ -71,6 +71,9 @@ class EventRecord(BaseRecord): # Also has created_at & ID from BaseRecord. ### Request body schemas +# If a request requires the event id, it is passed as a seperate argument. +# This is due to flask endpoint design. + @dataclass class BaseRequest: """A dataclass with to_dict and from_dict, similar to a @@ -84,11 +87,6 @@ def to_dict(self) -> dict[str, Any]: """Convert the record to a dictionary.""" return asdict(self) -@dataclass -class EventGet(BaseRequest): - """Request body schema for a request with event info as parameters.""" - id: CampusID - @dataclass class EventNew(BaseRequest): """Request body schema for a events.new operation.""" @@ -98,21 +96,26 @@ class EventNew(BaseRequest): start_time: schema.DateTime duration: int -@dataclass -class EventDelete(BaseRequest): - """Request body schema for a deletion request.""" - id: CampusID - @dataclass class EventUpdate(BaseRequest): """Request body schema for a events.update operation.""" - id: CampusID + # The event ID is passed seperately. name: str location: str location_url: str start_time: schema.DateTime duration: int +@dataclass +class EventDelete(BaseRequest): + """Request body schema for a deletion request.""" + # The event ID is passed seperately. + +@dataclass +class EventGet(BaseRequest): + """Request body schema for a request with event info as parameters.""" + # The event ID is passed seperately. + ### Model classes. class Event: @@ -168,24 +171,25 @@ def _try_get(self, event_id: CampusID) -> EventRecord: return event - def delete(self, fields: EventDelete) -> None: + def delete(self, id: CampusID, fields: EventDelete) -> None: """Delete an event by id.""" - self._try_get(fields.id) # Make sure it exists. + self._try_get(id) # Make sure it exists. try: - self.storage.delete_by_id(fields.id) + self.storage.delete_by_id(id) except Exception as e: raise api_errors.InternalError(message=str(e), error=e) - def get(self, fields: EventGet) -> EventRecord: + def get(self, id: CampusID, fields: EventGet) -> EventRecord: """Get an event by id.""" - return self._try_get(fields.id) + return self._try_get(id) - def update(self, fields: EventUpdate) -> None: + def update(self, id: CampusID, fields: EventUpdate) -> EventRecord: """Update an event by id.""" # Check if user exists first - self._try_get(fields.id) # Make sure it exists. + self._try_get(id) # Make sure it exists. try: - self.storage.update_by_id(fields.id, fields.to_dict()) + self.storage.update_by_id(id, fields.to_dict()) + return self._try_get(id) except Exception as e: raise api_errors.InternalError(message=str(e), error=e) From 0d076d463bcf283de32e9ff971c9f4e27841aeaa Mon Sep 17 00:00:00 2001 From: sparsetable Date: Tue, 11 Nov 2025 14:47:22 +0000 Subject: [PATCH 13/22] Implement description --- campus/models/event.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/campus/models/event.py b/campus/models/event.py index c16cc45d..6efc88a4 100644 --- a/campus/models/event.py +++ b/campus/models/event.py @@ -34,8 +34,6 @@ duration: INTEGER - duration of event in seconds """ -# TODO: Implement description. - TABLE = "events" @devops.block_env(devops.PRODUCTION) @@ -54,21 +52,29 @@ def init_db(): name TEXT, location TEXT, start_time TEXT, + description TEXT, location_url TEXT, duration INTEGER ) """ storage.init_table(schema) - -class EventRecord(BaseRecord): - """The event record stored in the events table.""" +@dataclass +class EventData: + """ + This class contains all information associated to an event, + except database-related information (no ID, no created_at). + Inherited by various event classes. + """ name: str location: str location_url: str start_time: schema.DateTime duration: int - # Also has created_at & ID from BaseRecord. + description: str + +class EventRecord(BaseRecord, EventData): + """The event record stored in the events table.""" ### Request body schemas # If a request requires the event id, it is passed as a seperate argument. @@ -88,23 +94,13 @@ def to_dict(self) -> dict[str, Any]: return asdict(self) @dataclass -class EventNew(BaseRequest): +class EventNew(BaseRequest, EventData): """Request body schema for a events.new operation.""" - name: str - location: str - location_url: str - start_time: schema.DateTime - duration: int @dataclass -class EventUpdate(BaseRequest): +class EventUpdate(BaseRequest, EventData): """Request body schema for a events.update operation.""" # The event ID is passed seperately. - name: str - location: str - location_url: str - start_time: schema.DateTime - duration: int @dataclass class EventDelete(BaseRequest): From ac9bdb510d746ed9a2aa4ba692eabba7aab6e32f Mon Sep 17 00:00:00 2001 From: sparsetable Date: Tue, 11 Nov 2025 14:52:57 +0000 Subject: [PATCH 14/22] Minor improvements to event flask --- campus/apps/api/routes/events.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/campus/apps/api/routes/events.py b/campus/apps/api/routes/events.py index 6d67d502..16fc7057 100644 --- a/campus/apps/api/routes/events.py +++ b/campus/apps/api/routes/events.py @@ -13,7 +13,7 @@ from campus.common.errors import api_errors from campus.models import event # Event model to be implemented from campus.common.utils import utc_time -import campus.yapper as campus_yapper +import campus.yapper as campus_yapper # TODO: IMPORTANT: Add event_id context when yapper emits bp = Blueprint('events', __name__, url_prefix='/events') bp.before_request(authenticate_client) @@ -69,6 +69,7 @@ def get_event_details(event_id: str) -> flask_validation.JsonResponse: return record.to_dict(), 200 +# TODO: Refactor to not make all fields required (:cry:) @bp.patch('/') def update_event(event_id: str) -> flask_validation.JsonResponse: """Edit an event occurrence.""" @@ -103,4 +104,4 @@ def delete_event(event_id: str) -> flask_validation.JsonResponse: events.delete(event_id, event.EventDelete.from_dict(payload)) yapper.emit('campus.events.delete') - return {}, 200 \ No newline at end of file + return {}, 204 # 204 right now because no content lol. \ No newline at end of file From 81aa285525dff4970d892b11fef258deeab48bf6 Mon Sep 17 00:00:00 2001 From: sparsetable Date: Wed, 12 Nov 2025 00:29:04 +0800 Subject: [PATCH 15/22] Add events to flask namespace --- campus/apps/api/routes/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/campus/apps/api/routes/__init__.py b/campus/apps/api/routes/__init__.py index 6f3ac2a7..75cb076d 100644 --- a/campus/apps/api/routes/__init__.py +++ b/campus/apps/api/routes/__init__.py @@ -8,6 +8,7 @@ "circles", "emailotp", "users", + "events" ] -from . import circles, emailotp, users, admin +from . import circles, emailotp, users, admin, events From dae8af1bf0ffa89ae68194b5bb2d1ef199f7610c Mon Sep 17 00:00:00 2001 From: JS Ng Date: Wed, 12 Nov 2025 08:14:09 +0000 Subject: [PATCH 16/22] chore: delete outdated files --- campus/apps/README.md | 30 - campus/apps/__init__.py | 41 - campus/apps/api/__init__.py | 23 - campus/apps/api/routes/__init__.py | 14 - campus/apps/api/routes/admin.py | 44 - campus/apps/api/routes/circles.py | 441 ----- campus/apps/api/routes/emailotp.py | 69 - campus/apps/api/routes/users.py | 342 ---- campus/apps/campusauth/__init__.py | 36 - campus/apps/campusauth/authentication.py | 81 - campus/apps/campusauth/routes.py | 285 --- campus/apps/oauth/__init__.py | 30 - campus/apps/oauth/discord.py | 207 -- campus/apps/oauth/github.py | 136 -- campus/apps/oauth/google.py | 219 --- campus/common/integration/__init__.py | 179 -- campus/common/integration/config.py | 56 - campus/common/integration/discord/api.json | 51 - campus/common/integration/flags.json | 14 - campus/common/integration/github/api.json | 45 - campus/common/integration/google/api.json | 38 - campus/common/integration/google/forms.json | 1765 ------------------ campus/common/integration/google/pubsub.json | 870 --------- campus/common/integration/schema.py | 44 - campus/common/validation/flask.py | 220 --- campus/common/validation/name.py | 32 - campus/common/webauth/header.py | 80 - campus/common/webauth/http.py | 86 - campus/common/webauth/token.py | 119 -- campus/models/__init__.py | 125 -- campus/models/base.py | 46 - campus/models/circle.py | 498 ----- campus/models/credentials.py | 214 --- campus/models/emailotp/__init__.py | 248 --- campus/models/emailotp/template.py | 51 - campus/models/emailotp/templates/email.html | 3 - campus/models/emailotp/templates/email.txt | 5 - campus/models/session/__init__.py | 244 --- campus/models/source/__init__.py | 151 -- campus/models/source/sourcetype.py | 57 - campus/models/token.py | 262 --- campus/models/user.py | 137 -- campus/vault/README.md | 29 - campus/vault/__init__.py | 124 -- campus/vault/access.py | 233 --- campus/vault/auth.py | 308 --- campus/vault/client.py | 317 ---- campus/vault/db.py | 114 -- campus/vault/routes/__init__.py | 20 - campus/vault/routes/access.py | 143 -- campus/vault/routes/clients.py | 157 -- campus/vault/routes/vaults.py | 105 -- campus/vault/vault.py | 237 --- 53 files changed, 9425 deletions(-) delete mode 100644 campus/apps/README.md delete mode 100644 campus/apps/__init__.py delete mode 100644 campus/apps/api/__init__.py delete mode 100644 campus/apps/api/routes/__init__.py delete mode 100644 campus/apps/api/routes/admin.py delete mode 100644 campus/apps/api/routes/circles.py delete mode 100644 campus/apps/api/routes/emailotp.py delete mode 100644 campus/apps/api/routes/users.py delete mode 100644 campus/apps/campusauth/__init__.py delete mode 100644 campus/apps/campusauth/authentication.py delete mode 100644 campus/apps/campusauth/routes.py delete mode 100644 campus/apps/oauth/__init__.py delete mode 100644 campus/apps/oauth/discord.py delete mode 100644 campus/apps/oauth/github.py delete mode 100644 campus/apps/oauth/google.py delete mode 100644 campus/common/integration/__init__.py delete mode 100644 campus/common/integration/config.py delete mode 100644 campus/common/integration/discord/api.json delete mode 100644 campus/common/integration/flags.json delete mode 100644 campus/common/integration/github/api.json delete mode 100644 campus/common/integration/google/api.json delete mode 100644 campus/common/integration/google/forms.json delete mode 100644 campus/common/integration/google/pubsub.json delete mode 100644 campus/common/integration/schema.py delete mode 100644 campus/common/validation/flask.py delete mode 100644 campus/common/validation/name.py delete mode 100644 campus/common/webauth/header.py delete mode 100644 campus/common/webauth/http.py delete mode 100644 campus/common/webauth/token.py delete mode 100644 campus/models/__init__.py delete mode 100644 campus/models/base.py delete mode 100644 campus/models/circle.py delete mode 100644 campus/models/credentials.py delete mode 100644 campus/models/emailotp/__init__.py delete mode 100644 campus/models/emailotp/template.py delete mode 100644 campus/models/emailotp/templates/email.html delete mode 100644 campus/models/emailotp/templates/email.txt delete mode 100644 campus/models/session/__init__.py delete mode 100644 campus/models/source/__init__.py delete mode 100644 campus/models/source/sourcetype.py delete mode 100644 campus/models/token.py delete mode 100644 campus/models/user.py delete mode 100644 campus/vault/README.md delete mode 100644 campus/vault/__init__.py delete mode 100644 campus/vault/access.py delete mode 100644 campus/vault/auth.py delete mode 100644 campus/vault/client.py delete mode 100644 campus/vault/db.py delete mode 100644 campus/vault/routes/__init__.py delete mode 100644 campus/vault/routes/access.py delete mode 100644 campus/vault/routes/clients.py delete mode 100644 campus/vault/routes/vaults.py delete mode 100644 campus/vault/vault.py diff --git a/campus/apps/README.md b/campus/apps/README.md deleted file mode 100644 index 21bee804..00000000 --- a/campus/apps/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Campus Apps - -Campus web applications and API endpoints. - -## Installation - -This subpackage is intended to be installed as part of a larger Campus deployment or for advanced users who need only the apps service. - -**Recommended installation method:** - -```bash -poetry install -``` - -This will install `campus-suite-apps` and all dependencies in a Poetry-managed virtual environment. - -> **Note:** Use `poetry install` for development and deployment. Ensure you are in the correct directory for the subpackage you wish to install. - -## Usage - -After installation, you can import Campus apps modules as needed: - -```python -from campus.apps.api import routes -from campus.apps.oauth import GoogleOAuth -``` - -## Not for Standalone Use - -This package is not intended to be used standalone by most users. For a full Campus deployment, use the `campus` meta-package (the root of the repository). diff --git a/campus/apps/__init__.py b/campus/apps/__init__.py deleted file mode 100644 index 76b16ee1..00000000 --- a/campus/apps/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -"""campus.apps - -This module contains the main applications for Campus. - -## Applications - -- api: The API endpoints for the Campus application. -- campusauth: Web endpoints for Campus (OAuth2) authentication. -- integrations: Integrations with third-party platforms and APIs. -- oauth: Campus OAuth2 implementation. -""" - -__all__ = ["api", "campusauth", "oauth"] - -from flask import Blueprint, Flask - -from . import api, campusauth, oauth - - -def init_app(app: Blueprint | Flask) -> None: - """Initialize the Campus app with all modules. - - This function sets up all Campus apps components including API, - authentication, and OAuth modules. - - Note: For creating new Flask applications, use the recommended pattern: - from campus.common.devops.deploy import create_app - import campus.apps - app = create_app(campus.apps) - - This ensures proper error handling and deployment configuration. - """ - api.init_app(app) - campusauth.init_app(app) - oauth.init_app(app) - # Use vault client to retrieve secret key since campus.apps deployment - # does not have VAULTDB_URI env var - if isinstance(app, Flask): - from campus.client.vault import get_vault - vault = get_vault() - app.secret_key = vault["campus"]["SECRET_KEY"].get()["value"] diff --git a/campus/apps/api/__init__.py b/campus/apps/api/__init__.py deleted file mode 100644 index d6dd0f8b..00000000 --- a/campus/apps/api/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""campus.apps.api - -Web API for Campus services. -""" - -__all__ = [] - -from flask import Blueprint, Flask - -from campus.apps.api import routes - - -def init_app(app: Flask | Blueprint) -> None: - """Initialise the API blueprint with the given Flask app.""" - # Organise API routes under api blueprint - bp = Blueprint('api_v1', __name__, url_prefix='/api/v1') - # Users need to be initialised first as other blueprints - # rely on user table - routes.circles.init_app(bp) - routes.emailotp.init_app(bp) - routes.users.init_app(bp) - routes.admin.init_app(bp) - app.register_blueprint(bp) diff --git a/campus/apps/api/routes/__init__.py b/campus/apps/api/routes/__init__.py deleted file mode 100644 index 75cb076d..00000000 --- a/campus/apps/api/routes/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""campus.apps.api.routes - -This is a namespace module for the Campus API routes. -""" - -__all__ = [ - "admin", - "circles", - "emailotp", - "users", - "events" -] - -from . import circles, emailotp, users, admin, events diff --git a/campus/apps/api/routes/admin.py b/campus/apps/api/routes/admin.py deleted file mode 100644 index 11b8925d..00000000 --- a/campus/apps/api/routes/admin.py +++ /dev/null @@ -1,44 +0,0 @@ -"""campus.apps.api.routes.admin - -Admin routes for Campus API. -""" - -from flask import Blueprint, Flask - -from campus.common import devops - -bp = Blueprint("admin", __name__, url_prefix="/admin") - - -# This file uses local imports to avoid exposing sensitive imports -# and polluting global space -# pylint: disable=import-outside-toplevel - -@bp.route("/status", methods=["GET"]) -def status(): - """Return admin status info.""" - return {"status": "ok", "message": "Admin endpoint is live."} - - -@bp.route("/init-db", methods=["POST"]) -def init_db(): - """Initialise the tables needed by api.""" - from campus import models, vault - models.init_db() - vault.init_db() - return {"status": "ok", "message": "Database initialised."} - -# Purge DB endpoint - - -@bp.route("/purge-db", methods=["POST"]) -def purge_db(): - """Purge the database.""" - from campus.storage import purge_all - purge_all() - return {"status": "ok", "message": f"{devops.ENV}: Database purged."} - - -def init_app(app: Blueprint | Flask) -> None: - """Register the admin blueprint with the given Flask app or blueprint.""" - app.register_blueprint(bp) diff --git a/campus/apps/api/routes/circles.py b/campus/apps/api/routes/circles.py deleted file mode 100644 index bb9def60..00000000 --- a/campus/apps/api/routes/circles.py +++ /dev/null @@ -1,441 +0,0 @@ -"""campus.apps.api.routes.circles - -API routes for the circles resource. -""" - -from flask import Blueprint, Flask - -import campus.yapper - -import campus.common.validation.flask as flask_validation -from campus.apps.campusauth import authenticate_client -from campus.common.errors import api_errors -from campus.models import circle - -bp = Blueprint('circles', __name__, url_prefix='/circles') -bp.before_request(authenticate_client) - -circles = circle.Circle() -# users = user.User() -yapper = campus.yapper.create() - - -def init_app(app: Flask | Blueprint) -> None: - """Initialise circle routes with the given Flask app/blueprint.""" - app.register_blueprint(bp) - -@bp.get('/') -def list_circles() -> flask_validation.JsonResponse: - """List all circles matching filter requirements.""" - filters = flask_validation.validate_request_and_extract_json( - circle.CircleRecordDict.__annotations__, - on_error=api_errors.raise_api_error, - ) or {} - result = circles.list(**filters) - return {"data": [circle.to_dict() for circle in result]}, 200 - -@bp.post('/') -def new_circle(*_: str) -> flask_validation.JsonResponse: - """Summary: - Create a new circle. - - Method: - POST /circles - - Path Parameters: - None - - Query Parameters: - None - - Request Body (application/json): - name: str (required) - The name of the new circle. - description: str (optional) - An optional description of the circle. - tag: CircleTag (required) - The tag that categorizes the circle. - parents: dict[CirclePath, AccessValue] (optional) - A mapping of parent circle paths to access values. - At least one parent is required (defaults to "admin" if omitted). - The full path is of the form: `{parent path} / {circle_id}`. - - Responses: - 201 Created: dict - JSON object representing the newly created circle. - Example: - { - "id": "design-team", - "name": "Design Team", - "description": "Handles UI/UX", - "tag": "project", - "parents": { - "/root/admin": "admin" - } - } - - 400 Bad Request: None - Returned if the request body is invalid or missing required fields. - - 422 Unprocessable Entity: None - Returned if validation fails (e.g., tag or parent format is incorrect). - """ - payload = flask_validation.validate_request_and_extract_json( - circle.CircleNew.__annotations__, - on_error=api_errors.raise_api_error, - ) - resource = circles.new(**payload) - flask_validation.validate_json_response( - circle.CircleResource.__annotations__, - resource, - on_error=api_errors.raise_api_error, - ) - yapper.emit('campus.circles.new') - return dict(resource), 201 - - -@bp.delete('/') -def delete_circle(circle_id: str) -> flask_validation.JsonResponse: - """Summary: - Delete a circle by its unique ID. - - Method: - DELETE /circles/{circle_id} - - Path Parameters: - circle_id: str (required) - The unique identifier of the circle to delete. - - Query Parameters: - None - - Request Body: - None - - Responses: - 200 OK: dict - Empty JSON object indicating successful deletion. - Example: - {} - - 409 Conflict: None - Returned if the circle does not exist. - - 500 Internal Server Error: None - Returned if an unexpected storage error occurs. - - Notes: - - This action is **destructive** and cannot be undone. - - Only admins or owners should perform this operation. - - Emits the event: `campus.circles.delete` - """ - circles.delete(circle_id) - yapper.emit('campus.circles.delete') - return {}, 200 - - -@bp.get('/') -def get_circle_details(circle_id: str) -> flask_validation.JsonResponse: - """Summary: - Retrieve detailed information about a specific circle. - - Method: - GET /circles/{circle_id} - - Path Parameters: - circle_id: str (required) - The unique identifier of the circle to retrieve. - - Query Parameters: - None - - Request Body: - None - - Responses: - 200 OK: dict - JSON object representing the circle details. - Example: - { - "id": "design-team", - "name": "Design Team", - "description": "Handles UI/UX", - "tag": "project", - "parents": { - "/root/admin": "admin" - }, - "sources": {} - } - - 409 Conflict: None - Returned if the circle does not exist. - - 500 Internal Server Error: None - Returned if a storage-level error occurs while retrieving the circle. - - Notes: - - If the circle is not found, a `ConflictError` is raised with message `"Circle not found"`. - - Response is validated against `CircleResource`. - - Currently, `sources` is always an empty object (may change in future with enrichment). - """ - resource = circles.get(circle_id) - flask_validation.validate_json_response( - circle.CircleResource.__annotations__, - resource, - on_error=api_errors.raise_api_error, - ) - return dict(resource), 200 - - -@bp.patch('/') -def edit_circle(circle_id: str) -> flask_validation.JsonResponse: - """Summary: - Update the name and/or description of an existing circle. - - Method: - PATCH /circles/{circle_id} - - Path Parameters: - circle_id: str (required) - The unique identifier of the circle to update. - - Query Parameters: - None - - Request Body (application/json): - name: str (optional) - The new name for the circle. - description: str (optional) - The new description for the circle. - - Responses: - 200 OK: dict - Empty object indicating that the update was successful. - Example: - {} - - 409 Conflict: None - Returned if the circle does not exist. - - 500 Internal Server Error: None - Returned if a storage-level error occurs during the update. - - Notes: - - At least one of `name` or `description` must be present in the request. - - If no changes are detected, the request is treated as a no-op (200 OK, no error). - - Emits the event: `campus.circles.update`. - """ - params = flask_validation.validate_request_and_extract_json( - circle.CircleUpdate.__annotations__, - on_error=api_errors.raise_api_error, - ) - circles.update(circle_id, **params) - yapper.emit('campus.circles.update') - return {}, 200 - - -@bp.post('//move') -def move_circle(circle_id: str) -> flask_validation.JsonResponse: - """Move a circle to a new parent.""" - return {"message": "Not implemented"}, 501 - - -@bp.get('//members') -def get_circle_members(circle_id: str) -> flask_validation.JsonResponse: - """Summary: - Retrieve the member IDs of a circle along with their access values. - - Method: - GET /circles/{circle_id}/members - - Path Parameters: - circle_id: str (required) - The unique identifier of the circle whose members are to be listed. - - Query Parameters: - None - - Request Body: - None - - Responses: - 200 OK: dict - A mapping of member IDs to their access values within the circle. - Example: - { - "user:alice": "admin", - "user:bob": "read" - } - - 409 Conflict: None - Returned if the circle does not exist. - - 500 Internal Server Error: None - Returned if a storage-level error occurs while retrieving members. - - Notes: - - The result includes only direct members of the circle. - - If the circle has no members, an empty object is returned. - - Emits no events and does not currently validate the response structure. - """ - resource = circles.members.list(circle_id) - # TODO: validate response - return resource, 200 - - -@bp.post('//members/add') -def add_circle_member(circle_id: str) -> flask_validation.JsonResponse: - """Summary: - Add a member to a circle with a specified access level. - - Method: - POST /circles/{circle_id}/members/add - - Path Parameters: - circle_id: str (required) - The unique identifier of the circle to which the member will be added. - - Query Parameters: - None - - Request Body (application/json): - member_id: str (required) - The ID of the circle being added as a member. - access_value: AccessValue (required) - The level of access the member will have in the parent circle. - - Responses: - 200 OK: dict - Empty object indicating the member was successfully added. - Example: - {} - - 409 Conflict: None - - Returned if the member circle does not exist. - - Returned if no changes were applied (e.g., member already exists with same access). - - 500 Internal Server Error: None - Returned if a storage-level error occurs during the operation. - - Notes: - - Emits the event: `campus.circles.members.add`. - - This operation directly updates a nested field (`members.{member_id}`) in storage. - - Only circle IDs can be added as members — not users or arbitrary entities. - """ - params = flask_validation.validate_request_and_extract_json( - circle.CircleMemberAdd.__annotations__, - on_error=api_errors.raise_api_error, - ) - circles.members.add(circle_id, **params) - yapper.emit('campus.circles.members.add') - return {}, 200 - - -@bp.delete('//members/remove') -def remove_circle_member(circle_id: str) -> flask_validation.JsonResponse: - """Summary: - Remove a member from a circle. - - Method: - DELETE /circles/{circle_id}/members/remove - - Path Parameters: - circle_id: str (required) - The ID of the circle from which the member will be removed. - - Query Parameters: - None - - Request Body (application/json): - member_id: str (required) - The ID of the member circle to remove. - - Responses: - 200 OK: dict - Empty object indicating the member was successfully removed. - Example: - {} - - 409 Conflict: None - - Returned if the target circle does not exist. - - Returned if the specified member is not part of the circle. - - Returned if no changes were applied during the removal. - - 500 Internal Server Error: None - Returned if a storage-level error occurs while removing the member. - - Notes: - - Emits the event: `campus.circles.members.remove`. - - Removal uses a MongoDB `$unset` on the path `members.{member_id}`. - - Only direct member circles can be removed this way. - - The response is not currently validated. - """ - params = flask_validation.validate_request_and_extract_json( - circle.CircleMemberRemove.__annotations__, - on_error=api_errors.raise_api_error, - ) - circles.members.remove(circle_id, **params) - # TODO: validate response - yapper.emit('campus.circles.members.remove') - return {}, 200 - -# TODO: Redesign for clearer access update: circles can have multiple parentage paths - - -@bp.patch('//members/') -def patch_circle_member(circle_id: str) -> flask_validation.JsonResponse: - """Summary: - Update the access level of a member within a circle. - - Method: - PATCH /circles/{circle_id}/members/{member_circle_id} - - Path Parameters: - circle_id: str (required) - The ID of the circle where the member's access is being updated. - member_circle_id: str (required) - The ID of the member circle whose access is being modified. - - Query Parameters: - None - - Request Body (application/json): - member_id: str (required) - Must match the path parameter `member_circle_id`. - access_value: AccessValue (required) - The new access level to assign to the member. - - Responses: - 200 OK: dict - Empty object indicating the access level was successfully updated. - Example: - {} - - 409 Conflict: None - - Returned if the member circle does not exist. - - Returned if no changes were applied. - - 500 Internal Server Error: None - Returned if a storage-level error occurs. - - Notes: - - This operation will create or update the member's access. - - Emits the event: `campus.circles.members.set`. - - No validation is currently performed to compare existing access. - """ - - params = flask_validation.validate_request_and_extract_json( - circle.CircleMemberSet.__annotations__, - on_error=api_errors.raise_api_error, - ) - circles.members.set(circle_id, **params) - # TODO: validate response - yapper.emit('campus.circles.members.set') - return {}, 200 - - -@bp.get('//users') -def get_circle_users(circle_id: str) -> flask_validation.JsonResponse: - # TODO: validate request - """Get users in a circle.""" - return {"message": "Not implemented"}, 501 diff --git a/campus/apps/api/routes/emailotp.py b/campus/apps/api/routes/emailotp.py deleted file mode 100644 index 0dbb9fd0..00000000 --- a/campus/apps/api/routes/emailotp.py +++ /dev/null @@ -1,69 +0,0 @@ -"""campus.apps.api.routes.emailotp - -API routes for the emailotp resource. -""" - -from flask import Blueprint, Flask - -import campus.common.validation.flask as flask_validation -from campus.apps.campusauth import authenticate_client -from campus.common.errors import api_errors -from campus.models import emailotp -from campus.services.email import create_email_sender - -from campus.models.emailotp import template - -bp = Blueprint('emailotp', __name__, url_prefix='/emailotp') -# All routes in this blueprint can be called by a client without token auth -# but must be authenticated with a client id and secret -bp.before_request(authenticate_client) - -otpauth = emailotp.EmailOTPAuth() - -EMAIL_PROVIDER = "smtp" - - -def init_app(app: Flask | Blueprint) -> None: - """Initialise emailotp routes with the given Flask app/blueprint.""" - app.register_blueprint(bp) - - -@bp.post('/request') -def request_otp() -> flask_validation.JsonResponse: - """Request a new OTP for email authentication.""" - payload = flask_validation.validate_request_and_extract_json( - emailotp.OTPRequest.__annotations__, - on_error=api_errors.raise_api_error, - ) - email = payload['email'] - # TODO: Validate email format - # TODO: Check if email is already registered - otp_code = otpauth.request(email) - - # Send OTP via email - email_sender = create_email_sender(EMAIL_PROVIDER) - error = email_sender.send_email( - recipient=email, - subject=template.subject("Campus", otp_code), - body=template.body("Campus", otp_code), - html_body=template.html_body("Campus", otp_code) - ) - if error: - api_errors.raise_api_error( - error["message"], - status_code=500, - error_message=str(error) - ) - return {"message": "OTP sent"}, 200 - -@bp.post('/verify') -def verify_otp() -> flask_validation.JsonResponse: - """Verify an OTP for email authentication.""" - # TODO: Validate email format - # TODO: Validate OTP format - payload = flask_validation.validate_request_and_extract_json( - emailotp.OTPVerify.__annotations__, - on_error=api_errors.raise_api_error, - ) - otpauth.verify(**payload) - return {"message": "OTP verified"}, 200 diff --git a/campus/apps/api/routes/users.py b/campus/apps/api/routes/users.py deleted file mode 100644 index 9fed9974..00000000 --- a/campus/apps/api/routes/users.py +++ /dev/null @@ -1,342 +0,0 @@ -"""campus.apps.api.routes.users - -API routes for the users resource. -""" - -from flask import Blueprint, Flask - -import campus.yapper - -import campus.common.validation.flask as flask_validation -from campus.apps.campusauth import authenticate_client -from campus.common.errors import api_errors -from campus.models import user - -bp = Blueprint('users', __name__, url_prefix='/users') -bp.before_request(authenticate_client) - -users = user.User() -yapper = campus.yapper.create() - - -def init_app(app: Flask | Blueprint) -> None: - """Summary: - Initialize and register all user-related routes with the given Flask app or blueprint. - - Method: - None - - Parameters: - app: Flask | Blueprint (required) - The Flask application/blueprint to which the user routes should be registered in. - - Path Parameters: - None - - Query Parameters: - None - - Request Body: - None - - Responses: - None - """ - app.register_blueprint(bp) - app.add_url_rule('/me', 'get_authenticated_user', get_authenticated_user) - - -# This view function is not registered with the blueprint -# It will be registered with the app in the init_app function -def get_authenticated_user(): - """Summary: - Retrieve the currently authenticated user's summary. - - Method: - GET /me - - Path Parameters: - None - - Query Parameters: - None - - Request Body: - None - - Responses: - 501 Not Implemented: dict - Returned because this function is not yet implemented: - { - "message": "not implemented" - } - """ - # TODO: Get user id from auth token - return {"message": "not implemented"}, 501 - - -@bp.get('/') -def list_users() -> flask_validation.JsonResponse: - """List all users (not yet implemented).""" - return {"message": "List users not implemented"}, 501 - - -@bp.post('/') -def new_user() -> flask_validation.JsonResponse: - """Summary: - Create a new user in the system. - - Method: - POST - - Parameters: - None - - Query Parameters: - None - - Request Body (application/json): - "email": str, # required, must be unique - "name": str # required - - Responses: - 201 Created: - Returns a `UserResource` object representing the newly created user: - { - "id": str, - "email": str, - "name": str, - "created_at": str, - "activated_at": str | null # may not be activated yet - } - - 400 Bad Request: - Invalid request body or missing required fields: - { - "error": str - } - - 409 Conflict: - User with the same email already exists: - { - "error": str - } - - 500 Internal Server Error: - Unexpected errors during creation: - { - "error": str - } - """ - payload = flask_validation.validate_request_and_extract_json( - user.UserNew.__annotations__, - on_error=api_errors.raise_api_error, - ) - resource = users.new(**payload) - flask_validation.validate_json_response( - user.UserResourceDict.__annotations__, - resource, - on_error=api_errors.raise_api_error, - ) - yapper.emit('campus.users.new') - return dict(resource), 201 - - -@bp.delete('/') -def delete_user(user_id: str) -> flask_validation.JsonResponse: - """Summary: - Delete a user by their unique ID. - - Method: - DELETE / - - Path Parameters: - user_id: str (required) - The unique identifier of the user to delete. - - Query Parameters: - None - - Request Body: - None - - Responses: - 200 OK: dict - Empty JSON object indicating successful deletion: - {} - - 404 Not Found: dict - Returned if no user exists with the given ID: - { - "error": str - } - - 500 Internal Server Error: dict - Unexpected server error during deletion: - { - "error": str - } - """ - users.delete(user_id) - yapper.emit('campus.users.delete') - return {}, 200 - - -@bp.get('/') -def get_user(user_id: str) -> flask_validation.JsonResponse: - """Summary: - Retrieve a single user's summary by their unique ID. - - Method: - GET / - - Path Parameters: - user_id: str (required) - The unique identifier of the user. - - Query Parameters: - None - - Request Body: - None - - Responses: - 200 OK: dict - JSON object containing the user's profile summary. - Example: - { - "profile": { - "id": str, - "email": str, - "name": str, - "created_at": str, - "activated_at": str | None - } - } - - 404 Not Found: dict - Returned if no user exists with the given ID: - { - "error": str - } - - 500 Internal Server Error: dict - Unexpected server error while retrieving the user: - { - "error": str - } - """ - summary = {} - record, _ = get_user_profile(user_id) - summary['profile'] = record - flask_validation.validate_json_response( - summary, - user.UserResourceDict.__annotations__, - on_error = api_errors.raise_api_error - ) - # future calls for other user info go here - return summary, 200 - - -@ bp.patch('/') -def patch_user_profile(user_id: str) -> flask_validation.JsonResponse: - """Summary: - Update a single user's profile by their unique ID. - - Method: - PATCH / - - Path Parameters: - user_id: str (required) - The unique identifier of the user to update. - - Query Parameters: - None - - Request Body: - JSON object matching the UserUpdate schema: - { - # Currently empty, can include optional fields to update in the future - } - - Responses: - 200 OK: dict - Empty JSON object indicating the update was successful: - {} - - 400 Bad Request: dict - Returned if the request body is invalid or contains unsupported fields: - { - "error": str - } - - 404 Not Found: dict - Returned if no user exists with the given ID: - { - "error": str - } - - 500 Internal Server Error: dict - Unexpected server error during the update: - { - "error": str - } - """ - payload = flask_validation.validate_request_and_extract_json( - user.UserUpdate.__annotations__, - on_error = api_errors.raise_api_error, - ) - users.update(user_id, **payload) - yapper.emit('campus.users.update') - return {}, 200 - - -@ bp.get('//profile') -def get_user_profile(user_id: str) -> flask_validation.JsonResponse: - """Summary: - Retrieve a single user's full profile by their unique ID. - - Method: - GET //profile - - Path Parameters: - user_id: str (required) - The unique identifier of the user. - - Query Parameters: - None - - Request Body: - None - - Responses: - 200 OK: dict - JSON object containing the user's full profile. - Example: - { - "id": str, - "email": str, - "name": str, - "created_at": str, - "activated_at": str | None - } - - 404 Not Found: dict - Returned if no user exists with the given ID: - { - "error": str - } - - 500 Internal Server Error: dict - Unexpected server error while retrieving the user: - { - "error": str - } - """ - resource= users.get(user_id) - flask_validation.validate_json_response( - user.UserResourceDict.__annotations__, - resource, - on_error = api_errors.raise_api_error, - ) - return dict(resource), 200 diff --git a/campus/apps/campusauth/__init__.py b/campus/apps/campusauth/__init__.py deleted file mode 100644 index da41a4d1..00000000 --- a/campus/apps/campusauth/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -"""campus.apps.campusauth - -Web endpoints for Campus authentication. -""" - -__all__ = [ - 'authenticate_client', - 'client_auth_required', -] - -from flask import Blueprint, Flask - -from campus.common import devops - -from .authentication import ( - authenticate_client, - client_auth_required -) - - -# pylint: disable=import-outside-toplevel - -def init_app(app: Flask | Blueprint) -> None: - """Initialise the campusauth blueprint with the given Flask app.""" - from . import routes - app.register_blueprint(routes.bp) - - -@devops.block_env(devops.PRODUCTION) -def init_db() -> None: - """Initialise the tables needed. - - This convenience function makes it easier to initialise tables for all - models. - """ - # campusauth relies on existing models and does not use any drums. diff --git a/campus/apps/campusauth/authentication.py b/campus/apps/campusauth/authentication.py deleted file mode 100644 index 3a464d4e..00000000 --- a/campus/apps/campusauth/authentication.py +++ /dev/null @@ -1,81 +0,0 @@ -"""campus.apps.campusauth.models - -Authentication and authorisation implementation for the Campus API. - -This module handles: -- authentication of credentials for Campus API requests. -- authorisation of requests based on access scopes. -""" - -from functools import wraps -from typing import Callable - -from flask import g, request - -from campus.client.vault import get_vault -from campus.common.errors import api_errors -from campus.common.webauth import http -from campus.models.token import Tokens -from campus.models.user import User - -tokens = Tokens() -users = User() -vault = get_vault() - - -def authenticate_client() -> tuple[dict[str, str], int] | None: - """Authenticate the client credentials using HTTP Basic Authentication. - - This function is meant to be used with Flask.before_request - to enforce authentication for all routes in the blueprint. - - Any return value from this function will be treated as a response - to the client, and the request will not be processed further. - - See https://flask.palletsprojects.com/en/stable/api/#flask.Flask.before_request - """ - req_header = dict(request.headers) - auth = ( - http.HttpAuthenticationScheme - .from_header(provider="campus", header=req_header) - .get_auth(header=req_header) - ) - match auth.scheme: - case "basic": - client_id, client_secret = auth.credentials() - # Raises API errors if auth fails - try: - auth_json = vault.clients.authenticate( - client_id, - client_secret - ) - except api_errors.NotFoundError: - # Client ID not found means invalid credentials - raise api_errors.UnauthorizedError( - "Invalid client credentials" - ) - # Passthrough other errors - if not auth_json["status"] == "success": - raise api_errors.UnauthorizedError( - "Invalid client credentials") - g.current_client = vault.clients.get(client_id) - case "bearer": - access_token = auth.value - # raises UnauthorizedError for invalid access_token - token = tokens.get(access_token) - g.current_user = users.get(token.user_id) - g.current_client = vault.client.get(token.client_id) - g.user_agent = request.headers.get("User-Agent", "") - return {"message": "Bearer auth not implemented"}, 501 - - -def client_auth_required(vf) -> Callable: - """View function decorator to enforce HTTP Basic Authentication.""" - @wraps(vf) - def authenticatedvf(*args, **kwargs): - """Wrapper function that returns the error response from - authentication, or calls the original function if authentication - is successful. - """ - return authenticate_client() or vf(*args, **kwargs) - return authenticatedvf diff --git a/campus/apps/campusauth/routes.py b/campus/apps/campusauth/routes.py deleted file mode 100644 index c56fa2e4..00000000 --- a/campus/apps/campusauth/routes.py +++ /dev/null @@ -1,285 +0,0 @@ -"""campus.apps.campusauth.routes - -Routes for Campus authentication - clients and users. - -Campus OAuth 2.0 Authorization Flow Diagram: - -+--------+ (A) +---------+ -| | ----------------->| | -| | Auth Request | | -| | | Campus | -| | (B) | Backend | -| | +---------------- +---------+ -| | | Redirect after -| | | session init +---------+ -| | +---------------->| | - | Google | -| | (C) | | -| | +---------------- +---------+ -| | | Redirect w Code +---------+ (D) +-----------+ -| | +---------------->| |---------------| Google | -| User | | |<--------------| Tokeninfo | -| | | Campus | Tokeninfo | Endpoint | -| | | Backend | +-----------+ -| | | (goog) | -| |<----------------- | | -+--------+ Authorised +---------+ - -Legend: -(A) User sends auth request to Campus -(B) User is redirected to Google for authentication and consent. -(C) Google redirects the user back to Campus with an authorization code. -(D) Campus backend exchanges the authorization code directly with Google's - token endpoint for user profile. -""" - -from typing import NotRequired, TypedDict, cast -from urllib.parse import urlencode - -from flask import ( - Blueprint, - g, - redirect, - session as flask_session, - url_for -) -from werkzeug.wrappers import Response - -from campus.common import schema -from campus.common.errors import api_errors -from campus.models.session import Sessions -from campus.models.token import Tokens -from campus.common.utils import secret -import campus.common.validation.flask as flask_validation - -from .authentication import client_auth_required - - -# No url prefix because authentication endpoints are not only used by the API -bp = Blueprint('campusauth', __name__, url_prefix='/') - -tokens = Tokens() -sessions = Sessions() - -DEFAULT_EXPIRY = 600 - - -class AuthorizationCodeRequest(TypedDict): - """Request data for OAuth2 authorization code request.""" - client_id: str - response_type: str - redirect_uri: str - scope: NotRequired[str] - state: NotRequired[str] - - -class TokenRequest(TypedDict): - """Request data for OAuth2 token request.""" - grant_type: str - code: str - redirect_uri: str - client_id: str - client_secret: str - - -# OAuth2 endpoints -@client_auth_required -@bp.get('/oauth2/authorize') -def oauth2_authorize() -> Response: - """Summary: - OAuth2 authorization endpoint for user consent and code grant. - 1. Validates the authorization request - 2. Authenticates the user (through Google Workspace) - 3. Verifies scope of consent - 4. Issues authorization code - 5. Redirects user to the specified redirect URI - - Method: - GET /oauth2/authorize - - Path Parameters: - None - - Query parameters: - - client_id: ID of OAuth client requesting authorization - - response_type: str (required) - Must be "code" for authorization code flow. - - redirect_uri: str (required) - URI to redirect the user to after authentication - - scope: str (optional) - Space-separated list of scopes requested by the client. - - state: str (optional) - Opaque value used by the client to maintain state between request and callback. - - Responses: - 501 Not Implemented: None - - Returned when missing scopes as this is not implemented yet. - 404 Session not found: None - - Returned when the user session is not found and user needs to log in. - 401 Invalid client_id/user_id: None - - Returned when the client_id or user_id in the session does not match the request. - 302 Found: Redirect - - Redirects to the specified redirect URI with the authorization code, as well as state if provided. - e.g. /oauth2/authorize?code=abc123&state=xyz - """ - req_json: AuthorizationCodeRequest = flask_validation.validate_request_and_extract_json( - AuthorizationCodeRequest.__annotations__, - on_error=api_errors.raise_api_error - ) # type: ignore - session = sessions.get() - if not session: - # TODO: Redirect to login with error message - return redirect(url_for("campusauth.login")) - # TODO: Verify scope of consent against user access level - # missing_scopes = tokens.validate_scope( - # session=dict(session), - # scopes=req_json.get("scope") or "" - # ) - # if missing_scopes: - # # TODO: redirect for additional scope authorization - # return "Additional scope authorization not implemented", 501 - # Issue authorization code - authorization_code = secret.generate_authorization_code() - target = req_json.get("state", "") - # TODO: Handle update errors - session = sessions.update( - session[schema.CAMPUS_KEY], - authorization_code=authorization_code, - # scopes=req_json.get("scope", "").split(), - redirect_uri=req_json["redirect_uri"], - target=target - ) - # Redirect user to the specified redirect URI - params = { - "code": authorization_code, - } - if target: - params["state"] = target - redirect_uri = f'{req_json["redirect_uri"]}?{urlencode(params)}' - return redirect(redirect_uri) - - -@client_auth_required -@bp.post('/oauth2/token') -def oauth2_token() -> flask_validation.JsonResponse: - """Summary: - OAuth2 token endpoint for exchanging authorization code for access token. - - Method: - POST /oauth2/token - - Path Parameters: - None - - Query Parameters: - None - - Request Body: - grant_type: str (required) - Must be "authorization_code". - code: str (required) - The authorization code received from `/oauth2/authorize`. - redirect_uri: str (required) - Must match the redirect_uri used in authorization. - client_id: str (required) - OAuth client identifier. - client_secret: str (required) - Secret key for the OAuth client. - - Responses: - 501 Not Implemented: None - - Returned as token issuance is not implemented yet, as well as completing the OAuth2 flow and revoking the session. - 400 Invalid authorization code: None - - Returned when the authorization code in the json does not match the one used in the session. - 400 Invalid redirect_uri: None - - Returned when the redirect_uri does not match the one used in the authorization request. - 400 Invalid grant_type: None - - Returned when grant_type is not "authorization_code" - 401 Not authenticated: None - - Returned when the session ID is not in the Flask session - """ - req_json: TokenRequest = cast( - TokenRequest, - flask_validation.validate_request_and_extract_json( - TokenRequest.__annotations__, - on_error=api_errors.raise_api_error - ) - ) - session = sessions.get() - if not session: - return {"error": "No OAuth session"}, 401 - if not req_json["grant_type"] == "authorization_code": - return {"error": "Invalid grant_type: expected 'authorization_code'"}, 400 - if req_json["code"] != session["authorization_code"]: - return {"error": "Invalid authorization code"}, 400 - # TODO: Issue token - token = tokens.new( - { - "client_id": g.current_client["id"], - "user_id": g.current_user["id"], - "scopes": session["scopes"], - }, - expiry_seconds=DEFAULT_EXPIRY - ) - # OAuth2 flow complete, revoke session - sessions.delete(session[schema.CAMPUS_KEY]) - return {"message": "Not implemented"}, 501 - - -@bp.get('/login') -def login() -> Response: - """Summary: - Login endpoint for user authentication. - - Method: - GET /login - - Path Parameters: - None - - Query Parameters: - None - - Responses: - 302 Found: Redirect - - If the user is already logged in, redirects to the home or dashboard page, - otherwise creates a new session and redirects to OAuth authorization. - """ - login_session = sessions.get() - if login_session: - # User already logged in, redirect to home or dashboard - return redirect(url_for('campus.home')) - # TODO: get user_id, client_id from auth header - sessions.new( - { - "user_id": flask_session["user_id"], - "client_id": flask_session["client_id"] - }, - expiry_seconds=DEFAULT_EXPIRY - ) - return redirect(url_for('oauth.google.authorize')) - - -@bp.post('/logout') -def logout() -> Response: - """Summary: - Logout endpoint for user session termination. - - Method: - POST /logout - - Path Parameters: - None - - Query Parameters: - None - - Request Body: - None - - Responses: - 501 Not Implemented: str - - Returned as revoking the login is not implemented yet. - """ - sessions.delete() - return redirect(url_for('campus.home')) diff --git a/campus/apps/oauth/__init__.py b/campus/apps/oauth/__init__.py deleted file mode 100644 index 7a25fcdc..00000000 --- a/campus/apps/oauth/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -"""campus.apps.oauth - -OAuth2 routes for integrations. -""" - -__all__ = [] - -from flask import Blueprint, Flask - -from campus.common import devops - -from . import discord, google - - -def init_app(app: Flask | Blueprint) -> None: - """Initialise the oauth blueprint with the given Flask app.""" - # Organise API routes under api blueprint - bp = Blueprint('oauth', __name__, url_prefix='/oauth') - discord.init_app(bp) - google.init_app(bp) - app.register_blueprint(bp) - - -@devops.block_env(devops.PRODUCTION) -def init_db() -> None: - """Initialise the tables needed by oauth. - - This convenience function makes it easier to initialise tables for all - models. - """ diff --git a/campus/apps/oauth/discord.py b/campus/apps/oauth/discord.py deleted file mode 100644 index 577833c8..00000000 --- a/campus/apps/oauth/discord.py +++ /dev/null @@ -1,207 +0,0 @@ -"""campus.apps.oauth.discord - -Routes for Discord OAuth2 Client Credentials flow. - -Reference: https://discord.com/developers/docs/topics/oauth2 - -Discord OAuth 2.0 Client Credentials Flow: - -+--------+ (A) +---------+ -| |------------------>| Discord | -| Campus | Token Request | | -| Server | (Basic Auth) +---------+ -| | (B) +---------+ -| |<------------------| | -| | Access Token | Campus | -| | | Backend | -+--------+ +---------+ - -Legend: -(A) Campus server requests an app token using client credentials - - Uses Basic Auth with client_id:client_secret - - Requests application-level scopes only -(B) Discord returns an access token for the application - - Token can be used for Discord API calls - - No user context, application-level access only - -This flow is suitable for server-to-server authentication where no user -interaction is required. -""" - -from typing import NotRequired, TypedDict - -from flask import Blueprint, Flask -from werkzeug.wrappers import Response - -from campus.client.vault import get_vault -from campus.common import integration, schema -from campus.common.errors import api_errors -from campus.common.utils import utc_time -from campus.common.validation import flask as flask_validation -from campus.common.webauth.oauth2.client_credentials import ( - OAuth2ClientCredentialsFlowScheme as OAuth2Flow -) -from campus.common.webauth.token import CredentialToken - -PROVIDER = 'discord' - -vault = get_vault()[PROVIDER] -bp = Blueprint(PROVIDER, __name__, url_prefix=f'/{PROVIDER}') -oauthconfig = integration.get_config(PROVIDER) -oauth2: OAuth2Flow = OAuth2Flow.from_json(oauthconfig, security="oauth2") - - -class TokenRequestSchema(TypedDict, total=False): - """Request schema for Discord app token request.""" - scopes: NotRequired[list[str]] # Optional list of scopes to request - - -class DiscordTokenResponseSchema(TypedDict): - """Response schema for Discord token endpoint.""" - access_token: str # Access token issued by Discord - token_type: str # Type of the token (e.g., "Bearer") - expires_in: int # Lifetime of the access token in seconds - scope: str # Scopes granted by the access token - - -class AppTokenResponseSchema(TypedDict): - """Normalized response schema for app token.""" - access_token: str # Access token for Discord API - token_type: str # Token type (Bearer) - expires_in: int # Token lifetime in seconds - scope: list[str] # Granted scopes as list - expires_at: schema.DateTime # RFC3339 timestamp when token expires - - -def init_app(app: Flask | Blueprint) -> None: - """Initialise Discord OAuth routes with the given Flask app/blueprint.""" - app.register_blueprint(bp) - - -@bp.post('/token') -def get_app_token() -> AppTokenResponseSchema: - """Get Discord app token using Client Credentials flow.""" - # Validate request parameters - params = flask_validation.validate_request_and_extract_json( - TokenRequestSchema.__annotations__, - on_error=api_errors.raise_api_error, - ) - - # Get client credentials from vault - client_id = vault["CLIENT_ID"].get()["value"] - client_secret = vault["CLIENT_SECRET"].get()["value"] - - # Use requested scopes or default application scopes - scopes = params.get('scopes', oauth2.scopes) - - # Get app token from Discord - token_response = oauth2.get_app_token( - client_id=client_id, - client_secret=client_secret, - scopes=scopes - ) - - # Validate token response - flask_validation.validate_json_response( - schema=DiscordTokenResponseSchema.__annotations__, - resp_json=token_response, - on_error=api_errors.raise_api_error, - ignore_extra=True, - ) - - # Create normalized response - credentials = CredentialToken(provider=PROVIDER, **token_response) - expires_at = schema.DateTime.utcafter(seconds=token_response["expires_in"]) - - response_data: AppTokenResponseSchema = { - "access_token": token_response["access_token"], - "token_type": token_response["token_type"], - "expires_in": token_response["expires_in"], - "scope": credentials.scopes, - "expires_at": expires_at, - } - flask_validation.validate_json_response( - schema=AppTokenResponseSchema.__annotations__, - resp_json=response_data, - on_error=api_errors.raise_api_error, - ) - return response_data - - -@bp.get('/token') -def get_app_token_get() -> AppTokenResponseSchema: - """Get Discord app token using Client Credentials flow (GET method).""" - # Validate query parameters - params = flask_validation.validate_request_and_extract_urlparams( - TokenRequestSchema.__annotations__, - on_error=api_errors.raise_api_error, - ignore_extra=False, - ) - - # Get client credentials from vault - client_id = vault["CLIENT_ID"].get()["value"] - client_secret = vault["CLIENT_SECRET"].get()["value"] - - # Use requested scopes or default application scopes - scopes = params.get('scopes', oauth2.scopes) - - # Get app token from Discord - token_response = oauth2.get_app_token( - client_id=client_id, - client_secret=client_secret, - scopes=scopes - ) - - # Validate token response - flask_validation.validate_json_response( - schema=DiscordTokenResponseSchema.__annotations__, - resp_json=token_response, - on_error=api_errors.raise_api_error, - ignore_extra=True, - ) - - # Create normalized response - credentials = CredentialToken(provider=PROVIDER, **token_response) - expires_at = schema.DateTime.utcafter(seconds=token_response["expires_in"]) - - response_data: AppTokenResponseSchema = { - "access_token": token_response["access_token"], - "token_type": token_response["token_type"], - "expires_in": token_response["expires_in"], - "scope": credentials.scopes, - "expires_at": expires_at, - } - flask_validation.validate_json_response( - schema=AppTokenResponseSchema.__annotations__, - resp_json=response_data, - on_error=api_errors.raise_api_error, - ) - return response_data - - -def get_valid_app_token(scopes: list[str] | None = None) -> CredentialToken: - """Retrieve a valid Discord app token. - - This function is not a flask view function. - Returns a cached token if valid, otherwise fetches a new one. - """ - # TODO: Implement token caching logic - # For now, always fetch a new token - - client_id = vault["CLIENT_ID"].get()["value"] - client_secret = vault["CLIENT_SECRET"].get()["value"] - - scopes = scopes or oauth2.scopes - - token_response = oauth2.get_app_token( - client_id=client_id, - client_secret=client_secret, - scopes=scopes - ) - - credentials = CredentialToken(provider=PROVIDER, **token_response) - - # TODO: Cache the token with expiry timestamp - # TODO: Check cache first and return cached token if still valid - - return credentials diff --git a/campus/apps/oauth/github.py b/campus/apps/oauth/github.py deleted file mode 100644 index a2634d44..00000000 --- a/campus/apps/oauth/github.py +++ /dev/null @@ -1,136 +0,0 @@ -from typing import Required, TypedDict - -from flask import Blueprint, Flask, redirect, request, url_for -from werkzeug.wrappers import Response - -from campus.client.vault import get_vault -from campus.common import integration, schema -from campus.common.errors import api_errors -from campus.common.utils import url -from campus.common.validation import flask as flask_validation -from campus.common.webauth.oauth2 import ( - OAuth2AuthorizationCodeFlowScheme as OAuth2Flow -) -from campus.common.webauth.token import CredentialToken -from campus.models.credentials import UserCredentials -from campus.models.session import Sessions - -PROVIDER = 'github' - -github_user_credentials = UserCredentials(PROVIDER) - -session = Sessions() -vault = get_vault()[PROVIDER] -bp = Blueprint(PROVIDER, __name__, url_prefix=f'/{PROVIDER}') -oauthconfig = integration.get_config(PROVIDER) -oauth2: OAuth2Flow = OAuth2Flow.from_json(oauth2config, security="oauth2") - -class AuthorizeRequestSchema(TypedDict, total=False): - - target: Required[str] - -class Callback(TypedDict, total=False): - - error: str - code: str - state: Required[str] - error_description: str - redirect_uri: Required[str] - -class GithubTokenResponseSchema(TypedDict): - access_token: str - token_type: str - scope: str - - -def init_app(app: Flask | Blueprint) -> None: - """Initialise auth routes with the given Flask app/blueprint.""" - app.register_blueprint(bp) - -@bp.get('/authorize') -def authorize() -> Response: - """Redirect to GitHub OAuth authorization endpoint.""" - params = flask_validation.validate_request_and_extract_urlparams( - AuthorizeRequestSchema.__annotations__, - on_error=api_errors.raise_api_error, - ignore_extra=False - ) - - session = oauth2.create_session( - client_id=vault["CLIENT_ID"].get()['value'], - scopes=oauth2.scopes, - target=params.pop("target") - ) - session.store() - redirect_uri = url.create_url("https", request.host, url_for(".callback")) - authorization_url = session.get_authorization_url( - redirect_uri, - **params, - ) - - return redirect(authorization_url) - - -@bp.get('/callback') -def callback() -> Response: - """Handle a GitHub OAuth callback request.""" - params = flask_validation.validate_request_and_extract_urlparams( - Callback.__annotations__, - on_error=api_errors.raise_api_error, - ignore_extra=True, - ) - - match params: - case {"error": _}: - api_errors.raise_api_error(401, **params) - case {"code": code, "state": state}: - session = oauth2.retrieve_session() - if not session or session.state != state: - api_errors.raise_api_error( - 401, - error="Invalid session state", - message="The session state does not match the expected value.", - ) - token_response = session.exchange_code_for_token( - code=code, - client_secret=vault["CLIENT_SECRET"].get()["value"], - ) - case _: - api_errors.raise_api_error(400, **params) - - flask_validation.validate_json_response( - schema=GithubTokenResponseSchema.__annotations__, - resp_json=token_response, - on_error=api_errors.raise_api_error, - ignore_extra=True, - ) - - credentials = CredentialToken(provider=PROVIDER, **token_response) - user_info = oauth2.get_user_info(credentials.access_token) - if "error" in user_info: - api_errors.raise_api_error(400, **user_info) - - github_user_credentials.store( - user_id=user_info["id"], # GitHub uses `id` as unique identifier - issued_at=schema.DateTime.utcnow(), - token=credentials.token, - ) - - return redirect(session.target) - -def get_valid_token(user_id: str) -> CredentialToken: - """Retrieve the user's GitHub OAuth token.""" - record = github_user_credentials.get(user_id) - token = CredentialToken.from_dict(PROVIDER, record["token"]) - if token.is_expired(): - oauth2.refresh_token( - token=token, - client_id=vault["CLIENT_ID"].get()["value"], - client_secret=vault["CLIENT_SECRET"].get()["value"], - ) - github_user_credentials.store( - user_id=record["user_id"], - issued_at=schema.DateTime.utcnow(), - token=token.to_dict(), - ) - return token diff --git a/campus/apps/oauth/google.py b/campus/apps/oauth/google.py deleted file mode 100644 index 507f4682..00000000 --- a/campus/apps/oauth/google.py +++ /dev/null @@ -1,219 +0,0 @@ -"""campus.apps.oauth.google - -Routes for Google OAuth2. - -Reference: https://developers.google.com/identity/protocols/oauth2/web-server - -Google OAuth 2.0 Authorization Flow Diagram: - -+--------+ (A) +---------+ -| |------------------>| Google | -| | Auth Request | | -| | +---------+ -| | (B) +---------+ -| | +-----------------| | -| User | +---------------->| | (C) +-----------+ -| | Redirect w/ Code | Campus |---------------| Google | -| | | Backend |<--------------| Token | -| | (D) | | Token Request | Endpoint | -| |<----------------- | | +-----------+ -+--------+ Redirect to sess +---------+ - target - -Legend: -(A) User is redirected from Campus to Google for authentication and consent. - - a server-side session is initialised - - the session_id is stored client-side -(B) Google redirects the user back to Campus with an authorization code. -(C) Campus backend exchanges the authorization code directly with Google's - token endpoint for user profile. -(D) Campus redirects user to session target. - -Apps and view functions sending the user to this endpoint must first establish -a server-side session with a target. -""" - -from typing import NotRequired, Required, TypedDict - -from flask import Blueprint, Flask, redirect, request, url_for -from werkzeug.wrappers import Response - -from campus.client.vault import get_vault -from campus.common import integration, schema -from campus.common.errors import api_errors -from campus.common.validation import flask as flask_validation -from campus.common.utils import url -from campus.common.webauth.oauth2 import ( - OAuth2AuthorizationCodeFlowScheme as OAuth2Flow -) -from campus.common.webauth.token import CredentialToken -from campus.models.credentials import UserCredentials -from campus.models.session import Sessions - -PROVIDER = 'google' - -google_user_credentials = UserCredentials(PROVIDER) - -sessions = Sessions() -vault = get_vault()[PROVIDER] -bp = Blueprint(PROVIDER, __name__, url_prefix=f'/{PROVIDER}') -oauthconfig = integration.get_config(PROVIDER) -oauth2: OAuth2Flow = OAuth2Flow.from_json(oauthconfig, security="oauth2") - - -class AuthorizeRequestSchema(TypedDict, total=False): - """Request type for Google OAuth authorization. - - Reference: https://developers.google.com/identity/protocols/oauth2/web-server#httprest - - NotRequired fields will be filled in by redirect endpoint. - """ - target: Required[str] # The URL to redirect to after successful authentication - login_hint: NotRequired[str] # Optional hint for the user's email address - # prompt: str # Not used - - -class Callback(TypedDict, total=False): - """Response type for a Google OAuth callback. - - This should be cast to either AuthorizationResponseSchema or - AuthorizationErrorResponseSchema based on the presence of 'code' or 'error'. - """ - error: str - code: str - state: Required[str] - error_description: str - error_uri: str - redirect_uri: Required[str] # The URI to redirect to after authorization - - -class GoogleTokenResponseSchema(TypedDict): - """Response schema for access token exchange.""" - access_token: str # Access token issued by the OAuth2 provider - token_type: str # Type of the token (e.g., "Bearer") - expires_in: int # Lifetime of the access token in seconds - scope: str # Scopes granted by the access token - # Optional refresh token for long-lived sessions - refresh_token: NotRequired[str] - - -def init_app(app: Flask | Blueprint) -> None: - """Initialise auth routes with the given Flask app/blueprint.""" - app.register_blueprint(bp) - - -@bp.get('/authorize') -def authorize() -> Response: - """Redirect to Google OAuth authorization endpoint.""" - # Requests to this endpoint are internal and should be strictly validated. - params = flask_validation.validate_request_and_extract_urlparams( - AuthorizeRequestSchema.__annotations__, - on_error=api_errors.raise_api_error, - ignore_extra=False, - ) - # Store session with target URL - session = oauth2.create_session( - client_id=vault["CLIENT_ID"].get()["value"], - scopes=oauth2.scopes, - target=params.pop('target'), - ) - session.store() - redirect_uri = url.create_url("https", request.host, url_for('.callback')) - authorization_url = session.get_authorization_url( - redirect_uri, - **params - ) - return redirect(authorization_url) - - -@bp.get('/callback') -def callback() -> Response: - """Handle a Google OAuth callback request.""" - # Requests to this endpoint are from Google, can be more loosely validated. - params = flask_validation.validate_request_and_extract_urlparams( - Callback.__annotations__, - on_error=api_errors.raise_api_error, - ignore_extra=True, - ) - - # Retrive session stored in /authorize - # Exchange the authorization code for an access token. - match params: - case {"error": _}: - api_errors.raise_api_error(401, **params) - case {"code": code, "state": state}: - session = oauth2.retrieve_session() - if not session or session.state != state: - api_errors.raise_api_error( - 401, - error="Invalid session state", - message="The session state does not match the expected value." - ) - token_response = session.exchange_code_for_token( - code=code, - client_secret=vault["CLIENT_SECRET"].get()["value"], - ) - case _: - api_errors.raise_api_error(400, **params) - - # Validate the token response - match token_response: - case {"error": "invalid_grant"}: - # Reference: https://developers.google.com/identity/protocols/oauth2/web-server#exchange-errors-invalid-grant - # TODO: display user-friendly error message before restarting flow - return redirect(url_for('authorize', target=session.target)) - case {"error": _}: - # Handle other errors returned by the token exchange - api_errors.raise_api_error(400, **token_response) - flask_validation.validate_json_response( - schema=GoogleTokenResponseSchema.__annotations__, - resp_json=token_response, - on_error=api_errors.raise_api_error, - ignore_extra=True, - ) - - # Verify requested scopes were granted - credentials = CredentialToken(provider=PROVIDER, **token_response) - missing_scopes = set(session.scopes) - set(credentials.scopes) - if missing_scopes: - api_errors.raise_api_error( - 403, - error="Missing scopes", - missing_scopes=list(missing_scopes), - granted_scopes=credentials.scopes, - ) - user_info = oauth2.get_user_info(credentials.access_token) - if "error" in user_info: - api_errors.raise_api_error(400, **user_info) - - # Store the access token in the user's credentials - google_user_credentials.store( - user_id=user_info["email"], - issued_at=schema.DateTime.utcnow(), - token=credentials.token, - ) - - # Session cleanup is expected to be handled automatically - return redirect(session.target) - - -def get_valid_token(user_id: str) -> CredentialToken: - """Retrieve the user's Google OAuth token. - - This function is not a flask view function. - """ - record = google_user_credentials.get(user_id) - token = CredentialToken.from_dict(PROVIDER, record["token"]) - if token.is_expired(): - # token is refreshed in-place - oauth2.refresh_token( - token=token, - client_id=vault["CLIENT_ID"].get()["value"], - client_secret=vault["CLIENT_SECRET"].get()["value"], - ) - google_user_credentials.store( - user_id=record["user_id"], - issued_at=schema.DateTime.utcnow(), - token=token.to_dict(), - ) - return token diff --git a/campus/common/integration/__init__.py b/campus/common/integration/__init__.py deleted file mode 100644 index 9112fdb4..00000000 --- a/campus/common/integration/__init__.py +++ /dev/null @@ -1,179 +0,0 @@ -"""apps.common.models.integration - -This module provides classes for creating and managing Campus integrations, -which are connections to third-party platforms and APIs. -""" - -__all__ = [ - "get_config", -] - -from collections.abc import Mapping -from typing import Any, NotRequired, TypedDict - -from campus.common.devops import Env -from campus.common import devops -from campus.storage import get_collection - -from . import config, schema - -from .config import Security, IntegrationConfigSchema, SecurityConfigSchema, get_config - -Url = str - -COLLECTION = "integrations" - - -# TODO: Refactor settings into a separate model -@devops.block_env(devops.PRODUCTION) -def init_db(): - """Initialize the collections needed by the model. - - This function is intended to be called only in a test environment or - staging. - For MongoDB, collections are created automatically on first insert. - """ - # Initialize the collection (creates it if needed) - storage = get_collection(COLLECTION) - storage.init_collection() - - # Ensure meta record exists - meta_list = storage.get_matching({"@meta": True}) - if not meta_list: - storage.insert_one({"@meta": True}) - meta_list = storage.get_matching({"@meta": True}) - meta_record = meta_list[0] - - -class PollingCapabilities(TypedDict): - """Polling capabilities of an integration.""" - supported: bool # Whether polling is supported - # Default polling interval in seconds, if applicable - default_poll_interval: NotRequired[int] - - -class WebhookCapabilities(TypedDict): - """Webhook capabilities of an integration.""" - supported: bool # Whether the integration supports webhooks - events: list[str] # List of events that can trigger webhooks - - -class CommonCapabilities(TypedDict): - """Common capabilities of an integration/sourcetype that Campus can use.""" - polling: PollingCapabilities - webhooks: WebhookCapabilities - - -class IntegrationConfig(TypedDict, total=False): - """Config schema for an integration. - - Since integrations are imported from config and not created or - mutated via API, they do not have a CampusID or created_at field. - They are identified via name as the PK. - """ - name: str # lowercase, e.g. "google" | "discord" | "github" - description: str - servers: Mapping[Env, Url] - api_doc: Url # URL to OpenAPI spec or API documentation - security: Mapping[Security, SecurityConfigSchema] - capabilities: CommonCapabilities - enabled: bool # Whether the integration is enabled in Campus - - -class Integration: - """Encapsulate integration properties and interactions.""" - - def __init__( - self, - provider: str, - description: str, - servers: Mapping[Env, Url], - api_doc: Url, - security: Mapping[Security, SecurityConfigSchema], - capabilities: CommonCapabilities, - enabled: bool | None = None - ): - self.provider = provider - self.description = description - self.servers = servers - self.api_doc = api_doc - self.security = security - self.capabilities = capabilities - # Disabled/enabled status is stored in storage - # Sync from storage on init - self.enabled = enabled - self.sync_status() - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "Integration": - """Instantiate from a dict (e.g., loaded from JSON).""" - return cls( - provider=data["provider"], - description=data["description"], - servers=data["servers"], - api_doc=data["api_doc"], - security=data["security"], - capabilities=data["capabilities"], - # Default to False if not present - enabled=data.get("enabled", None) - ) - - def to_dict(self) -> dict[str, Any]: - """Convert to dict for serialization.""" - return { - "provider": self.provider, - "description": self.description, - "servers": self.servers, - "api_doc": self.api_doc, - "security": self.security, - "capabilities": self.capabilities, - "enabled": self.enabled - } - - def disable(self): - """Disable an integration.""" - self.enabled = False - self.sync_status() - - def enable(self): - """Enable an integration.""" - self.enabled = True - self.sync_status() - - def sync_status(self): - """Sync an integration status to storage.""" - storage = get_collection(COLLECTION) - meta_list = storage.get_matching({"@meta": True}) - if not meta_list: - raise ValueError("No @meta document found in storage.") - meta_record = meta_list[0] - if not "enabled" in meta_record["integrations"][self.provider]: - # If the integration is not registered, register it - storage.update_matching( - {"@meta": True}, - {f"integrations.{self.provider}.enabled": False} - ) - - integration = storage.get_matching({ - "@meta": True, - f"integrations.{self.provider}": 1 - })[0] - assert isinstance(integration, dict) and "enabled" in integration - if self.enabled is None: - # Instance is inited but not yet synced - # Set the enabled status from integration doc - self.enabled = bool(integration["enabled"]) - else: - # Update storage from instance enabled status - storage.update_matching( - {"@meta": True}, - {f"integrations.{self.provider}.enabled": bool(self.enabled)} - ) - - -class IntegrationCredentials(TypedDict): - """Credentials for an integration.""" - client_id: str - client_secret: str - access_token: NotRequired[str] # Optional access token for OAuth2 flows - refresh_token: NotRequired[str] # Optional refresh token for OAuth2 flows diff --git a/campus/common/integration/config.py b/campus/common/integration/config.py deleted file mode 100644 index 70641ff4..00000000 --- a/campus/common/integration/config.py +++ /dev/null @@ -1,56 +0,0 @@ -"""apps.integration.config - -Config for third-party integrations. -""" - -__all__ = [ - "HttpScheme", - "IntegrationConfigSchema", - "OAuth2AuthorizationCodeConfigSchema", - "OAuth2Flow", - "Security", - "SecurityConfigSchema", - "get_config", -] - -import json -import os -from pathlib import Path -from typing import Any - -from .schema import ( - HttpScheme, - OAuth2Flow, - Security, - IntegrationConfigSchema, - SecurityConfigSchema, - OAuth2AuthorizationCodeConfigSchema -) - -CONFIG_ROOT = os.path.dirname(__file__) - - -def _chdir_config_root(): - """Change the current working directory to the config root.""" - if os.getcwd() != CONFIG_ROOT: - os.chdir(CONFIG_ROOT) - -def _load_json(file_path: str) -> dict[str, Any]: - """Load a JSON file and return its content.""" - if Path(file_path).suffix != ".json": - raise ValueError("{file_path}: File must be .json") - if Path(file_path).is_absolute(): - raise ValueError(f"{file_path}: File path must be relative") - fullpath = Path(CONFIG_ROOT) / file_path - try: - with open(fullpath, "r", encoding="utf-8") as f: - return json.load(f) - except FileNotFoundError as err: - raise FileNotFoundError(f"File not found: {fullpath}") from err - - -def get_config(provider: str, resource: str = "api") -> dict[str, Any]: - """Get the configuration for a specific integration provider.""" - # Load the provider's config file - config = _load_json(f"{provider}/{resource}.json") - return config diff --git a/campus/common/integration/discord/api.json b/campus/common/integration/discord/api.json deleted file mode 100644 index c5dd45fc..00000000 --- a/campus/common/integration/discord/api.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "provider": "discord", - "description": "Discord API platform integration for Campus. Provides access to Discord services via OAuth2.", - "servers": { - "development": "https://discord.com/api", - "production": "https://discord.com/api", - "staging": "https://discord.com/api", - "testing": "https://discord.com/api" - }, - "api_doc": "https://discord.com/developers/docs/intro", - "discovery_url": "https://raw.githubusercontent.com/discord/discord-api-spec/refs/heads/main/specs/openapi.json", - "security": { - "oauth2": { - "security_scheme": "oauth2", - "flow": "clientCredentials", - "scopes": [ - "applications.commands", - "bot", - "webhook.incoming" - ], - "token_url": "https://discord.com/api/oauth2/token" - }, - "oauth2_user": { - "security_scheme": "oauth2", - "flow": "authorizationCode", - "scopes": [ - "identify", - "email", - "guilds" - ], - "authorization_url": "https://discord.com/api/oauth2/authorize", - "token_url": "https://discord.com/api/oauth2/token", - "user_info_url": "https://discord.com/api/users/@me", - "extra_params": { - "prompt": "consent" - } - } - }, - "capabilities": { - "polling": { - "supported": false - }, - "webhooks": { - "supported": true, - "events": [ - "MESSAGE_CREATE", - "GUILD_MEMBER_ADD" - ] - } - } -} diff --git a/campus/common/integration/flags.json b/campus/common/integration/flags.json deleted file mode 100644 index 0e0f829a..00000000 --- a/campus/common/integration/flags.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "google": { - "forms": { - "enabled": true, - "discovery_url": "https://forms.googleapis.com/$discovery/rest?version=v1", - "filename": "forms.json" - }, - "pubsub": { - "enabled": true, - "discovery_url": "https://pubsub.googleapis.com/$discovery/rest?version=v1beta1a", - "filename": "pubsub.json" - } - } -} \ No newline at end of file diff --git a/campus/common/integration/github/api.json b/campus/common/integration/github/api.json deleted file mode 100644 index 4f5a1b4c..00000000 --- a/campus/common/integration/github/api.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "provider": "github", - "description": "GitHub API platform integration for Campus. Provides access to GitHub services via OAuth2.", - "servers": { - "development": "https://api.github.com", - "production": "https://api.github.com", - "staging": "https://api.github.com", - "testing": "https://api.github.com" - }, - "api_doc": "https://docs.github.com/en/rest", - "discovery_url": "https://github.com/github/rest-api-description/releases/download/v2.1.0/descriptions.zip", - "security": { - "oauth2": { - "security_scheme": "oauth2", - "flow": "authorizationCode", - "headers": { - "Accept": "application/json" - }, - "scopes": [ - "read:user", - "user:email" - ], - "authorization_url": "https://github.com/login/oauth/authorize", - "token_url": "https://github.com/login/oauth/access_token", - "user_info_url": "https://api.github.com/user", - "extra_params": { - "allow_signup": "true" - } - } - }, - "capabilities": { - "polling": { - "supported": true, - "default_poll_interval": 300 - }, - "webhooks": { - "supported": true, - "events": [ - "push", - "pull_request", - "issues" - ] - } - } -} diff --git a/campus/common/integration/google/api.json b/campus/common/integration/google/api.json deleted file mode 100644 index 1eb42e11..00000000 --- a/campus/common/integration/google/api.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "provider": "google", - "description": "Google API platform integration for Campus. Provides access to Google services via OAuth2.", - "servers": { - "development": "https://www.googleapis.com", - "production": "https://www.googleapis.com", - "staging": "https://www.googleapis.com", - "testing": "https://www.googleapis.com" - }, - "api_doc": "https://developers.google.com/apis-explorer", - "discovery_url": "https://www.googleapis.com/discovery/v1/apis", - "security": { - "oauth2": { - "security_scheme": "oauth2", - "flow": "authorizationCode", - "scopes": [ - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile" - ], - "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth", - "token_url": "https://oauth2.googleapis.com/token", - "user_info_url": "https://www.googleapis.com/oauth2/v3/userinfo", - "extra_params": { - "access_type": "offline", - "include_granted_scopes": "true" - } - } - }, - "capabilities": { - "polling": { - "supported": true, - "default_poll_interval": 300 - }, - "webhooks": { - "supported": false - } - } -} diff --git a/campus/common/integration/google/forms.json b/campus/common/integration/google/forms.json deleted file mode 100644 index 925d5bea..00000000 --- a/campus/common/integration/google/forms.json +++ /dev/null @@ -1,1765 +0,0 @@ -{ - "icons": { - "x16": "http://www.google.com/images/icons/product/search-16.gif", - "x32": "http://www.google.com/images/icons/product/search-32.gif" - }, - "resources": { - "forms": { - "methods": { - "create": { - "id": "forms.forms.create", - "path": "v1/forms", - "flatPath": "v1/forms", - "httpMethod": "POST", - "parameters": { - "unpublished": { - "description": "Optional. Whether the form is unpublished. If set to `true`, the form doesn't accept responses. If set to `false` or unset, the form is published and accepts responses.", - "location": "query", - "type": "boolean" - } - }, - "parameterOrder": [], - "request": { - "$ref": "Form" - }, - "response": { - "$ref": "Form" - }, - "scopes": [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/forms.body" - ], - "description": "Create a new form using the title given in the provided form message in the request. *Important:* Only the form.info.title and form.info.document_title fields are copied to the new form. All other fields including the form description, items and settings are disallowed. To create a new form and add items, you must first call forms.create to create an empty form with a title and (optional) document title, and then call forms.update to add the items." - }, - "get": { - "id": "forms.forms.get", - "path": "v1/forms/{formId}", - "flatPath": "v1/forms/{formId}", - "httpMethod": "GET", - "parameters": { - "formId": { - "description": "Required. The form ID.", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "formId" - ], - "response": { - "$ref": "Form" - }, - "scopes": [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/forms.body", - "https://www.googleapis.com/auth/forms.body.readonly" - ], - "description": "Get a form." - }, - "batchUpdate": { - "id": "forms.forms.batchUpdate", - "path": "v1/forms/{formId}:batchUpdate", - "flatPath": "v1/forms/{formId}:batchUpdate", - "httpMethod": "POST", - "parameters": { - "formId": { - "description": "Required. The form ID.", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "formId" - ], - "request": { - "$ref": "BatchUpdateFormRequest" - }, - "response": { - "$ref": "BatchUpdateFormResponse" - }, - "scopes": [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/forms.body" - ], - "description": "Change the form with a batch of updates." - }, - "setPublishSettings": { - "id": "forms.forms.setPublishSettings", - "path": "v1/forms/{formId}:setPublishSettings", - "flatPath": "v1/forms/{formId}:setPublishSettings", - "httpMethod": "POST", - "parameters": { - "formId": { - "description": "Required. The ID of the form. You can get the id from Form.form_id field.", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "formId" - ], - "request": { - "$ref": "SetPublishSettingsRequest" - }, - "response": { - "$ref": "SetPublishSettingsResponse" - }, - "scopes": [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/forms.body" - ], - "description": "Updates the publish settings of a form. Legacy forms aren't supported because they don't have the `publish_settings` field." - } - }, - "resources": { - "responses": { - "methods": { - "get": { - "id": "forms.forms.responses.get", - "path": "v1/forms/{formId}/responses/{responseId}", - "flatPath": "v1/forms/{formId}/responses/{responseId}", - "httpMethod": "GET", - "parameters": { - "formId": { - "description": "Required. The form ID.", - "location": "path", - "required": true, - "type": "string" - }, - "responseId": { - "description": "Required. The response ID within the form.", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "formId", - "responseId" - ], - "response": { - "$ref": "FormResponse" - }, - "scopes": [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/forms.responses.readonly" - ], - "description": "Get one response from the form." - }, - "list": { - "id": "forms.forms.responses.list", - "path": "v1/forms/{formId}/responses", - "flatPath": "v1/forms/{formId}/responses", - "httpMethod": "GET", - "parameters": { - "formId": { - "description": "Required. ID of the Form whose responses to list.", - "location": "path", - "required": true, - "type": "string" - }, - "filter": { - "description": "Which form responses to return. Currently, the only supported filters are: * timestamp \u003e *N* which means to get all form responses submitted after (but not at) timestamp *N*. * timestamp \u003e= *N* which means to get all form responses submitted at and after timestamp *N*. For both supported filters, timestamp must be formatted in RFC3339 UTC \"Zulu\" format. Examples: \"2014-10-02T15:01:23Z\" and \"2014-10-02T15:01:23.045123456Z\".", - "location": "query", - "type": "string" - }, - "pageSize": { - "description": "The maximum number of responses to return. The service may return fewer than this value. If unspecified or zero, at most 5000 responses are returned.", - "location": "query", - "type": "integer", - "format": "int32" - }, - "pageToken": { - "description": "A page token returned by a previous list response. If this field is set, the form and the values of the filter must be the same as for the original request.", - "location": "query", - "type": "string" - } - }, - "parameterOrder": [ - "formId" - ], - "response": { - "$ref": "ListFormResponsesResponse" - }, - "scopes": [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/forms.responses.readonly" - ], - "description": "List a form's responses." - } - } - }, - "watches": { - "methods": { - "create": { - "id": "forms.forms.watches.create", - "path": "v1/forms/{formId}/watches", - "flatPath": "v1/forms/{formId}/watches", - "httpMethod": "POST", - "parameters": { - "formId": { - "description": "Required. ID of the Form to watch.", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "formId" - ], - "request": { - "$ref": "CreateWatchRequest" - }, - "response": { - "$ref": "Watch" - }, - "scopes": [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/forms.body", - "https://www.googleapis.com/auth/forms.body.readonly", - "https://www.googleapis.com/auth/forms.responses.readonly" - ], - "description": "Create a new watch. If a watch ID is provided, it must be unused. For each invoking project, the per form limit is one watch per Watch.EventType. A watch expires seven days after it is created (see Watch.expire_time)." - }, - "list": { - "id": "forms.forms.watches.list", - "path": "v1/forms/{formId}/watches", - "flatPath": "v1/forms/{formId}/watches", - "httpMethod": "GET", - "parameters": { - "formId": { - "description": "Required. ID of the Form whose watches to list.", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "formId" - ], - "response": { - "$ref": "ListWatchesResponse" - }, - "scopes": [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/forms.body", - "https://www.googleapis.com/auth/forms.body.readonly", - "https://www.googleapis.com/auth/forms.responses.readonly" - ], - "description": "Return a list of the watches owned by the invoking project. The maximum number of watches is two: For each invoker, the limit is one for each event type per form." - }, - "renew": { - "id": "forms.forms.watches.renew", - "path": "v1/forms/{formId}/watches/{watchId}:renew", - "flatPath": "v1/forms/{formId}/watches/{watchId}:renew", - "httpMethod": "POST", - "parameters": { - "formId": { - "description": "Required. The ID of the Form.", - "location": "path", - "required": true, - "type": "string" - }, - "watchId": { - "description": "Required. The ID of the Watch to renew.", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "formId", - "watchId" - ], - "request": { - "$ref": "RenewWatchRequest" - }, - "response": { - "$ref": "Watch" - }, - "scopes": [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/forms.body", - "https://www.googleapis.com/auth/forms.body.readonly", - "https://www.googleapis.com/auth/forms.responses.readonly" - ], - "description": "Renew an existing watch for seven days. The state of the watch after renewal is `ACTIVE`, and the `expire_time` is seven days from the renewal. Renewing a watch in an error state (e.g. `SUSPENDED`) succeeds if the error is no longer present, but fail otherwise. After a watch has expired, RenewWatch returns `NOT_FOUND`." - }, - "delete": { - "id": "forms.forms.watches.delete", - "path": "v1/forms/{formId}/watches/{watchId}", - "flatPath": "v1/forms/{formId}/watches/{watchId}", - "httpMethod": "DELETE", - "parameters": { - "formId": { - "description": "Required. The ID of the Form.", - "location": "path", - "required": true, - "type": "string" - }, - "watchId": { - "description": "Required. The ID of the Watch to delete.", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "formId", - "watchId" - ], - "response": { - "$ref": "Empty" - }, - "scopes": [ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/forms.body", - "https://www.googleapis.com/auth/forms.body.readonly", - "https://www.googleapis.com/auth/forms.responses.readonly" - ], - "description": "Delete a watch." - } - } - } - } - } - }, - "ownerName": "Google", - "servicePath": "", - "fullyEncodeReservedExpansion": true, - "auth": { - "oauth2": { - "scopes": { - "https://www.googleapis.com/auth/drive": { - "description": "See, edit, create, and delete all of your Google Drive files" - }, - "https://www.googleapis.com/auth/drive.file": { - "description": "See, edit, create, and delete only the specific Google Drive files you use with this app" - }, - "https://www.googleapis.com/auth/drive.readonly": { - "description": "See and download all your Google Drive files" - }, - "https://www.googleapis.com/auth/forms.body": { - "description": "See, edit, create, and delete all your Google Forms forms" - }, - "https://www.googleapis.com/auth/forms.body.readonly": { - "description": "See all your Google Forms forms" - }, - "https://www.googleapis.com/auth/forms.responses.readonly": { - "description": "See all responses to your Google Forms forms" - } - } - } - }, - "discoveryVersion": "v1", - "baseUrl": "https://forms.googleapis.com/", - "basePath": "", - "canonicalName": "Forms", - "kind": "discovery#restDescription", - "version_module": true, - "documentationLink": "https://developers.google.com/workspace/forms/api", - "name": "forms", - "title": "Google Forms API", - "ownerDomain": "google.com", - "description": "Reads and writes Google Forms and responses.", - "parameters": { - "access_token": { - "type": "string", - "description": "OAuth access token.", - "location": "query" - }, - "alt": { - "type": "string", - "description": "Data format for response.", - "default": "json", - "enum": [ - "json", - "media", - "proto" - ], - "enumDescriptions": [ - "Responses with Content-Type of application/json", - "Media download with context-dependent Content-Type", - "Responses with Content-Type of application/x-protobuf" - ], - "location": "query" - }, - "callback": { - "type": "string", - "description": "JSONP", - "location": "query" - }, - "fields": { - "type": "string", - "description": "Selector specifying which fields to include in a partial response.", - "location": "query" - }, - "key": { - "type": "string", - "description": "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.", - "location": "query" - }, - "oauth_token": { - "type": "string", - "description": "OAuth 2.0 token for the current user.", - "location": "query" - }, - "prettyPrint": { - "type": "boolean", - "description": "Returns response with indentations and line breaks.", - "default": "true", - "location": "query" - }, - "quotaUser": { - "type": "string", - "description": "Available to use for quota purposes for server-side applications. Can be any arbitrary string assigned to a user, but should not exceed 40 characters.", - "location": "query" - }, - "upload_protocol": { - "type": "string", - "description": "Upload protocol for media (e.g. \"raw\", \"multipart\").", - "location": "query" - }, - "uploadType": { - "type": "string", - "description": "Legacy upload protocol for media (e.g. \"media\", \"multipart\").", - "location": "query" - }, - "$.xgafv": { - "type": "string", - "description": "V1 error format.", - "enum": [ - "1", - "2" - ], - "enumDescriptions": [ - "v1 error format", - "v2 error format" - ], - "location": "query" - } - }, - "batchPath": "batch", - "revision": "20250624", - "protocol": "rest", - "version": "v1", - "schemas": { - "Form": { - "id": "Form", - "description": "A Google Forms document. A form is created in Drive, and deleting a form or changing its access protections is done via the [Drive API](https://developers.google.com/drive/api/v3/about-sdk).", - "type": "object", - "properties": { - "formId": { - "description": "Output only. The form ID.", - "readOnly": true, - "type": "string" - }, - "info": { - "description": "Required. The title and description of the form.", - "$ref": "Info" - }, - "settings": { - "description": "The form's settings. This must be updated with UpdateSettingsRequest; it is ignored during CreateForm and UpdateFormInfoRequest.", - "$ref": "FormSettings" - }, - "items": { - "description": "Required. A list of the form's items, which can include section headers, questions, embedded media, etc.", - "type": "array", - "items": { - "$ref": "Item" - } - }, - "revisionId": { - "description": "Output only. The revision ID of the form. Used in the WriteControl in update requests to identify the revision on which the changes are based. The format of the revision ID may change over time, so it should be treated opaquely. A returned revision ID is only guaranteed to be valid for 24 hours after it has been returned and cannot be shared across users. If the revision ID is unchanged between calls, then the form *content* has not changed. Conversely, a changed ID (for the same form and user) usually means the form *content* has been updated; however, a changed ID can also be due to internal factors such as ID format changes. Form content excludes form metadata, including: * sharing settings (who has access to the form) * publish_settings (if the form supports publishing and if it is published)", - "readOnly": true, - "type": "string" - }, - "responderUri": { - "description": "Output only. The form URI to share with responders. This opens a page that allows the user to submit responses but not edit the questions. For forms that have publish_settings value set, this is the published form URI.", - "readOnly": true, - "type": "string" - }, - "linkedSheetId": { - "description": "Output only. The ID of the linked Google Sheet which is accumulating responses from this Form (if such a Sheet exists).", - "readOnly": true, - "type": "string" - }, - "publishSettings": { - "description": "Output only. The publishing settings for a form. This field isn't set for legacy forms because they don't have the publish_settings field. All newly created forms support publish settings. Forms with publish_settings value set can call SetPublishSettings API to publish or unpublish the form.", - "readOnly": true, - "$ref": "PublishSettings" - } - } - }, - "Info": { - "id": "Info", - "description": "The general information for a form.", - "type": "object", - "properties": { - "title": { - "description": "Required. The title of the form which is visible to responders.", - "type": "string" - }, - "documentTitle": { - "description": "Output only. The title of the document which is visible in Drive. If Info.title is empty, `document_title` may appear in its place in the Google Forms UI and be visible to responders. `document_title` can be set on create, but cannot be modified by a batchUpdate request. Please use the [Google Drive API](https://developers.google.com/drive/api/v3/reference/files/update) if you need to programmatically update `document_title`.", - "readOnly": true, - "type": "string" - }, - "description": { - "description": "The description of the form.", - "type": "string" - } - } - }, - "FormSettings": { - "id": "FormSettings", - "description": "A form's settings.", - "type": "object", - "properties": { - "quizSettings": { - "description": "Settings related to quiz forms and grading.", - "$ref": "QuizSettings" - }, - "emailCollectionType": { - "description": "Optional. The setting that determines whether the form collects email addresses from respondents.", - "type": "string", - "enumDescriptions": [ - "Unspecified. This value is unused.", - "The form doesn't collect email addresses. Default value if the form owner uses a Google account.", - "The form collects email addresses automatically based on the account of the signed-in user. Default value if the form owner uses a Google Workspace account.", - "The form collects email addresses using a field that the respondent completes on the form." - ], - "enum": [ - "EMAIL_COLLECTION_TYPE_UNSPECIFIED", - "DO_NOT_COLLECT", - "VERIFIED", - "RESPONDER_INPUT" - ] - } - } - }, - "QuizSettings": { - "id": "QuizSettings", - "description": "Settings related to quiz forms and grading. These must be updated with the UpdateSettingsRequest.", - "type": "object", - "properties": { - "isQuiz": { - "description": "Whether this form is a quiz or not. When true, responses are graded based on question Grading. Upon setting to false, all question Grading is deleted.", - "type": "boolean" - } - } - }, - "Item": { - "id": "Item", - "description": "A single item of the form. `kind` defines which kind of item it is.", - "type": "object", - "properties": { - "questionItem": { - "description": "Poses a question to the user.", - "$ref": "QuestionItem" - }, - "questionGroupItem": { - "description": "Poses one or more questions to the user with a single major prompt.", - "$ref": "QuestionGroupItem" - }, - "pageBreakItem": { - "description": "Starts a new page with a title.", - "$ref": "PageBreakItem" - }, - "textItem": { - "description": "Displays a title and description on the page.", - "$ref": "TextItem" - }, - "imageItem": { - "description": "Displays an image on the page.", - "$ref": "ImageItem" - }, - "videoItem": { - "description": "Displays a video on the page.", - "$ref": "VideoItem" - }, - "itemId": { - "description": "The item ID. On creation, it can be provided but the ID must not be already used in the form. If not provided, a new ID is assigned.", - "type": "string" - }, - "title": { - "description": "The title of the item.", - "type": "string" - }, - "description": { - "description": "The description of the item.", - "type": "string" - } - } - }, - "QuestionItem": { - "id": "QuestionItem", - "description": "A form item containing a single question.", - "type": "object", - "properties": { - "question": { - "description": "Required. The displayed question.", - "$ref": "Question" - }, - "image": { - "description": "The image displayed within the question.", - "$ref": "Image" - } - } - }, - "Question": { - "id": "Question", - "description": "Any question. The specific type of question is known by its `kind`.", - "type": "object", - "properties": { - "choiceQuestion": { - "description": "A respondent can choose from a pre-defined set of options.", - "$ref": "ChoiceQuestion" - }, - "textQuestion": { - "description": "A respondent can enter a free text response.", - "$ref": "TextQuestion" - }, - "scaleQuestion": { - "description": "A respondent can choose a number from a range.", - "$ref": "ScaleQuestion" - }, - "dateQuestion": { - "description": "A respondent can enter a date.", - "$ref": "DateQuestion" - }, - "timeQuestion": { - "description": "A respondent can enter a time.", - "$ref": "TimeQuestion" - }, - "fileUploadQuestion": { - "description": "A respondent can upload one or more files.", - "$ref": "FileUploadQuestion" - }, - "rowQuestion": { - "description": "A row of a QuestionGroupItem.", - "$ref": "RowQuestion" - }, - "ratingQuestion": { - "description": "A respondent can choose a rating from a pre-defined set of icons.", - "$ref": "RatingQuestion" - }, - "questionId": { - "description": "Read only. The question ID. On creation, it can be provided but the ID must not be already used in the form. If not provided, a new ID is assigned.", - "type": "string" - }, - "required": { - "description": "Whether the question must be answered in order for a respondent to submit their response.", - "type": "boolean" - }, - "grading": { - "description": "Grading setup for the question.", - "$ref": "Grading" - } - } - }, - "ChoiceQuestion": { - "id": "ChoiceQuestion", - "description": "A radio/checkbox/dropdown question.", - "type": "object", - "properties": { - "type": { - "description": "Required. The type of choice question.", - "type": "string", - "enumDescriptions": [ - "Default value. Unused.", - "Radio buttons: All choices are shown to the user, who can only pick one of them.", - "Checkboxes: All choices are shown to the user, who can pick any number of them.", - "Drop-down menu: The choices are only shown to the user on demand, otherwise only the current choice is shown. Only one option can be chosen." - ], - "enum": [ - "CHOICE_TYPE_UNSPECIFIED", - "RADIO", - "CHECKBOX", - "DROP_DOWN" - ] - }, - "options": { - "description": "Required. List of options that a respondent must choose from.", - "type": "array", - "items": { - "$ref": "Option" - } - }, - "shuffle": { - "description": "Whether the options should be displayed in random order for different instances of the quiz. This is often used to prevent cheating by respondents who might be looking at another respondent's screen, or to address bias in a survey that might be introduced by always putting the same options first or last.", - "type": "boolean" - } - } - }, - "Option": { - "id": "Option", - "description": "An option for a Choice question.", - "type": "object", - "properties": { - "goToAction": { - "description": "Section navigation type.", - "type": "string", - "enumDescriptions": [ - "Default value. Unused.", - "Go to the next section.", - "Go back to the beginning of the form.", - "Submit form immediately." - ], - "enum": [ - "GO_TO_ACTION_UNSPECIFIED", - "NEXT_SECTION", - "RESTART_FORM", - "SUBMIT_FORM" - ] - }, - "goToSectionId": { - "description": "Item ID of section header to go to.", - "type": "string" - }, - "value": { - "description": "Required. The choice as presented to the user.", - "type": "string" - }, - "image": { - "description": "Display image as an option.", - "$ref": "Image" - }, - "isOther": { - "description": "Whether the option is \"other\". Currently only applies to `RADIO` and `CHECKBOX` choice types, but is not allowed in a QuestionGroupItem.", - "type": "boolean" - } - } - }, - "Image": { - "id": "Image", - "description": "Data representing an image.", - "type": "object", - "properties": { - "sourceUri": { - "description": "Input only. The source URI is the URI used to insert the image. The source URI can be empty when fetched.", - "type": "string" - }, - "contentUri": { - "description": "Output only. A URI from which you can download the image; this is valid only for a limited time.", - "readOnly": true, - "type": "string" - }, - "altText": { - "description": "A description of the image that is shown on hover and read by screenreaders.", - "type": "string" - }, - "properties": { - "description": "Properties of an image.", - "$ref": "MediaProperties" - } - } - }, - "MediaProperties": { - "id": "MediaProperties", - "description": "Properties of the media.", - "type": "object", - "properties": { - "alignment": { - "description": "Position of the media.", - "type": "string", - "enumDescriptions": [ - "Default value. Unused.", - "Left align.", - "Right align.", - "Center." - ], - "enum": [ - "ALIGNMENT_UNSPECIFIED", - "LEFT", - "RIGHT", - "CENTER" - ] - }, - "width": { - "description": "The width of the media in pixels. When the media is displayed, it is scaled to the smaller of this value or the width of the displayed form. The original aspect ratio of the media is preserved. If a width is not specified when the media is added to the form, it is set to the width of the media source. Width must be between 0 and 740, inclusive. Setting width to 0 or unspecified is only permitted when updating the media source.", - "type": "integer", - "format": "int32" - } - } - }, - "TextQuestion": { - "id": "TextQuestion", - "description": "A text-based question.", - "type": "object", - "properties": { - "paragraph": { - "description": "Whether the question is a paragraph question or not. If not, the question is a short text question.", - "type": "boolean" - } - } - }, - "ScaleQuestion": { - "id": "ScaleQuestion", - "description": "A scale question. The user has a range of numeric values to choose from.", - "type": "object", - "properties": { - "low": { - "description": "Required. The lowest possible value for the scale.", - "type": "integer", - "format": "int32" - }, - "high": { - "description": "Required. The highest possible value for the scale.", - "type": "integer", - "format": "int32" - }, - "lowLabel": { - "description": "The label to display describing the lowest point on the scale.", - "type": "string" - }, - "highLabel": { - "description": "The label to display describing the highest point on the scale.", - "type": "string" - } - } - }, - "DateQuestion": { - "id": "DateQuestion", - "description": "A date question. Date questions default to just month + day.", - "type": "object", - "properties": { - "includeTime": { - "description": "Whether to include the time as part of the question.", - "type": "boolean" - }, - "includeYear": { - "description": "Whether to include the year as part of the question.", - "type": "boolean" - } - } - }, - "TimeQuestion": { - "id": "TimeQuestion", - "description": "A time question.", - "type": "object", - "properties": { - "duration": { - "description": "`true` if the question is about an elapsed time. Otherwise it is about a time of day.", - "type": "boolean" - } - } - }, - "FileUploadQuestion": { - "id": "FileUploadQuestion", - "description": "A file upload question. The API currently does not support creating file upload questions.", - "type": "object", - "properties": { - "folderId": { - "description": "Required. The ID of the Drive folder where uploaded files are stored.", - "type": "string" - }, - "types": { - "description": "File types accepted by this question.", - "type": "array", - "items": { - "type": "string", - "enumDescriptions": [ - "Default value. Unused.", - "No restrictions on type.", - "A Google Docs document.", - "A Google Slides presentation.", - "A Google Sheets spreadsheet.", - "A drawing.", - "A PDF.", - "An image.", - "A video.", - "An audio file." - ], - "enum": [ - "FILE_TYPE_UNSPECIFIED", - "ANY", - "DOCUMENT", - "PRESENTATION", - "SPREADSHEET", - "DRAWING", - "PDF", - "IMAGE", - "VIDEO", - "AUDIO" - ] - } - }, - "maxFiles": { - "description": "Maximum number of files that can be uploaded for this question in a single response.", - "type": "integer", - "format": "int32" - }, - "maxFileSize": { - "description": "Maximum number of bytes allowed for any single file uploaded to this question.", - "type": "string", - "format": "int64" - } - } - }, - "RowQuestion": { - "id": "RowQuestion", - "description": "Configuration for a question that is part of a question group.", - "type": "object", - "properties": { - "title": { - "description": "Required. The title for the single row in the QuestionGroupItem.", - "type": "string" - } - } - }, - "RatingQuestion": { - "id": "RatingQuestion", - "description": "A rating question. The user has a range of icons to choose from.", - "type": "object", - "properties": { - "ratingScaleLevel": { - "description": "Required. The rating scale level of the rating question.", - "type": "integer", - "format": "int32" - }, - "iconType": { - "description": "Required. The icon type to use for the rating.", - "type": "string", - "enumDescriptions": [ - "Default value. Unused.", - "A star icon.", - "A heart icon.", - "A thumbs down icon." - ], - "enum": [ - "RATING_ICON_TYPE_UNSPECIFIED", - "STAR", - "HEART", - "THUMB_UP" - ] - } - } - }, - "Grading": { - "id": "Grading", - "description": "Grading for a single question", - "type": "object", - "properties": { - "pointValue": { - "description": "Required. The maximum number of points a respondent can automatically get for a correct answer. This must not be negative.", - "type": "integer", - "format": "int32" - }, - "correctAnswers": { - "description": "Required. The answer key for the question. Responses are automatically graded based on this field.", - "$ref": "CorrectAnswers" - }, - "whenRight": { - "description": "The feedback displayed for correct responses. This feedback can only be set for multiple choice questions that have correct answers provided.", - "$ref": "Feedback" - }, - "whenWrong": { - "description": "The feedback displayed for incorrect responses. This feedback can only be set for multiple choice questions that have correct answers provided.", - "$ref": "Feedback" - }, - "generalFeedback": { - "description": "The feedback displayed for all answers. This is commonly used for short answer questions when a quiz owner wants to quickly give respondents some sense of whether they answered the question correctly before they've had a chance to officially grade the response. General feedback cannot be set for automatically graded multiple choice questions.", - "$ref": "Feedback" - } - } - }, - "CorrectAnswers": { - "id": "CorrectAnswers", - "description": "The answer key for a question.", - "type": "object", - "properties": { - "answers": { - "description": "A list of correct answers. A quiz response can be automatically graded based on these answers. For single-valued questions, a response is marked correct if it matches any value in this list (in other words, multiple correct answers are possible). For multiple-valued (`CHECKBOX`) questions, a response is marked correct if it contains exactly the values in this list.", - "type": "array", - "items": { - "$ref": "CorrectAnswer" - } - } - } - }, - "CorrectAnswer": { - "id": "CorrectAnswer", - "description": "A single correct answer for a question. For multiple-valued (`CHECKBOX`) questions, several `CorrectAnswer`s may be needed to represent a single correct response option.", - "type": "object", - "properties": { - "value": { - "description": "Required. The correct answer value. See the documentation for TextAnswer.value for details on how various value types are formatted.", - "type": "string" - } - } - }, - "Feedback": { - "id": "Feedback", - "description": "Feedback for a respondent about their response to a question.", - "type": "object", - "properties": { - "text": { - "description": "Required. The main text of the feedback.", - "type": "string" - }, - "material": { - "description": "Additional information provided as part of the feedback, often used to point the respondent to more reading and resources.", - "type": "array", - "items": { - "$ref": "ExtraMaterial" - } - } - } - }, - "ExtraMaterial": { - "id": "ExtraMaterial", - "description": "Supplementary material to the feedback.", - "type": "object", - "properties": { - "link": { - "description": "Text feedback.", - "$ref": "TextLink" - }, - "video": { - "description": "Video feedback.", - "$ref": "VideoLink" - } - } - }, - "TextLink": { - "id": "TextLink", - "description": "Link for text.", - "type": "object", - "properties": { - "uri": { - "description": "Required. The URI.", - "type": "string" - }, - "displayText": { - "description": "Required. Display text for the URI.", - "type": "string" - } - } - }, - "VideoLink": { - "id": "VideoLink", - "description": "Link to a video.", - "type": "object", - "properties": { - "youtubeUri": { - "description": "The URI of a YouTube video.", - "type": "string" - }, - "displayText": { - "description": "Required. The display text for the link.", - "type": "string" - } - } - }, - "QuestionGroupItem": { - "id": "QuestionGroupItem", - "description": "Defines a question that comprises multiple questions grouped together.", - "type": "object", - "properties": { - "grid": { - "description": "The question group is a grid with rows of multiple choice questions that share the same options. When `grid` is set, all questions in the group must be of kind `row`.", - "$ref": "Grid" - }, - "questions": { - "description": "Required. A list of questions that belong in this question group. A question must only belong to one group. The `kind` of the group may affect what types of questions are allowed.", - "type": "array", - "items": { - "$ref": "Question" - } - }, - "image": { - "description": "The image displayed within the question group above the specific questions.", - "$ref": "Image" - } - } - }, - "Grid": { - "id": "Grid", - "description": "A grid of choices (radio or check boxes) with each row constituting a separate question. Each row has the same choices, which are shown as the columns.", - "type": "object", - "properties": { - "columns": { - "description": "Required. The choices shared by each question in the grid. In other words, the values of the columns. Only `CHECK_BOX` and `RADIO` choices are allowed.", - "$ref": "ChoiceQuestion" - }, - "shuffleQuestions": { - "description": "If `true`, the questions are randomly ordered. In other words, the rows appear in a different order for every respondent.", - "type": "boolean" - } - } - }, - "PageBreakItem": { - "id": "PageBreakItem", - "description": "A page break. The title and description of this item are shown at the top of the new page.", - "type": "object", - "properties": {} - }, - "TextItem": { - "id": "TextItem", - "description": "A text item.", - "type": "object", - "properties": {} - }, - "ImageItem": { - "id": "ImageItem", - "description": "An item containing an image.", - "type": "object", - "properties": { - "image": { - "description": "Required. The image displayed in the item.", - "$ref": "Image" - } - } - }, - "VideoItem": { - "id": "VideoItem", - "description": "An item containing a video.", - "type": "object", - "properties": { - "video": { - "description": "Required. The video displayed in the item.", - "$ref": "Video" - }, - "caption": { - "description": "The text displayed below the video.", - "type": "string" - } - } - }, - "Video": { - "id": "Video", - "description": "Data representing a video.", - "type": "object", - "properties": { - "youtubeUri": { - "description": "Required. A YouTube URI.", - "type": "string" - }, - "properties": { - "description": "Properties of a video.", - "$ref": "MediaProperties" - } - } - }, - "PublishSettings": { - "id": "PublishSettings", - "description": "The publishing settings of a form.", - "type": "object", - "properties": { - "publishState": { - "description": "Optional. The publishing state of a form. When updating `publish_state`, both `is_published` and `is_accepting_responses` must be set. However, setting `is_accepting_responses` to `true` and `is_published` to `false` isn't supported and returns an error.", - "$ref": "PublishState" - } - } - }, - "PublishState": { - "id": "PublishState", - "description": "The publishing state of a form.", - "type": "object", - "properties": { - "isPublished": { - "description": "Required. Whether the form is published and visible to others.", - "type": "boolean" - }, - "isAcceptingResponses": { - "description": "Required. Whether the form accepts responses. If `is_published` is set to `false`, this field is forced to `false`.", - "type": "boolean" - } - } - }, - "FormResponse": { - "id": "FormResponse", - "description": "A form response.", - "type": "object", - "properties": { - "formId": { - "description": "Output only. The form ID.", - "readOnly": true, - "type": "string" - }, - "responseId": { - "description": "Output only. The response ID.", - "readOnly": true, - "type": "string" - }, - "createTime": { - "description": "Output only. Timestamp for the first time the response was submitted.", - "readOnly": true, - "type": "string", - "format": "google-datetime" - }, - "lastSubmittedTime": { - "description": "Output only. Timestamp for the most recent time the response was submitted. Does not track changes to grades.", - "readOnly": true, - "type": "string", - "format": "google-datetime" - }, - "respondentEmail": { - "description": "Output only. The email address (if collected) for the respondent.", - "readOnly": true, - "type": "string" - }, - "answers": { - "description": "Output only. The actual answers to the questions, keyed by question_id.", - "readOnly": true, - "type": "object", - "additionalProperties": { - "$ref": "Answer" - } - }, - "totalScore": { - "description": "Output only. The total number of points the respondent received for their submission Only set if the form was a quiz and the response was graded. This includes points automatically awarded via autograding adjusted by any manual corrections entered by the form owner.", - "readOnly": true, - "type": "number", - "format": "double" - } - } - }, - "Answer": { - "id": "Answer", - "description": "The submitted answer for a question.", - "type": "object", - "properties": { - "textAnswers": { - "description": "Output only. The specific answers as text.", - "readOnly": true, - "$ref": "TextAnswers" - }, - "fileUploadAnswers": { - "description": "Output only. The answers to a file upload question.", - "readOnly": true, - "$ref": "FileUploadAnswers" - }, - "questionId": { - "description": "Output only. The question's ID. See also Question.question_id.", - "readOnly": true, - "type": "string" - }, - "grade": { - "description": "Output only. The grade for the answer if the form was a quiz.", - "readOnly": true, - "$ref": "Grade" - } - } - }, - "TextAnswers": { - "id": "TextAnswers", - "description": "A question's answers as text.", - "type": "object", - "properties": { - "answers": { - "description": "Output only. Answers to a question. For multiple-value ChoiceQuestions, each answer is a separate value.", - "readOnly": true, - "type": "array", - "items": { - "$ref": "TextAnswer" - } - } - } - }, - "TextAnswer": { - "id": "TextAnswer", - "description": "An answer to a question represented as text.", - "type": "object", - "properties": { - "value": { - "description": "Output only. The answer value. Formatting used for different kinds of question: * ChoiceQuestion * `RADIO` or `DROP_DOWN`: A single string corresponding to the option that was selected. * `CHECKBOX`: Multiple strings corresponding to each option that was selected. * TextQuestion: The text that the user entered. * ScaleQuestion: A string containing the number that was selected. * DateQuestion * Without time or year: MM-DD e.g. \"05-19\" * With year: YYYY-MM-DD e.g. \"1986-05-19\" * With time: MM-DD HH:MM e.g. \"05-19 14:51\" * With year and time: YYYY-MM-DD HH:MM e.g. \"1986-05-19 14:51\" * TimeQuestion: String with time or duration in HH:MM format e.g. \"14:51\" * RowQuestion within QuestionGroupItem: The answer for each row of a QuestionGroupItem is represented as a separate Answer. Each will contain one string for `RADIO`-type choices or multiple strings for `CHECKBOX` choices.", - "readOnly": true, - "type": "string" - } - } - }, - "FileUploadAnswers": { - "id": "FileUploadAnswers", - "description": "All submitted files for a FileUpload question.", - "type": "object", - "properties": { - "answers": { - "description": "Output only. All submitted files for a FileUpload question.", - "readOnly": true, - "type": "array", - "items": { - "$ref": "FileUploadAnswer" - } - } - } - }, - "FileUploadAnswer": { - "id": "FileUploadAnswer", - "description": "Info for a single file submitted to a file upload question.", - "type": "object", - "properties": { - "fileId": { - "description": "Output only. The ID of the Google Drive file.", - "readOnly": true, - "type": "string" - }, - "fileName": { - "description": "Output only. The file name, as stored in Google Drive on upload.", - "readOnly": true, - "type": "string" - }, - "mimeType": { - "description": "Output only. The MIME type of the file, as stored in Google Drive on upload.", - "readOnly": true, - "type": "string" - } - } - }, - "Grade": { - "id": "Grade", - "description": "Grade information associated with a respondent's answer to a question.", - "type": "object", - "properties": { - "score": { - "description": "Output only. The numeric score awarded for the answer.", - "readOnly": true, - "type": "number", - "format": "double" - }, - "correct": { - "description": "Output only. Whether the question was answered correctly or not. A zero-point score is not enough to infer incorrectness, since a correctly answered question could be worth zero points.", - "readOnly": true, - "type": "boolean" - }, - "feedback": { - "description": "Output only. Additional feedback given for an answer.", - "readOnly": true, - "$ref": "Feedback" - } - } - }, - "ListFormResponsesResponse": { - "id": "ListFormResponsesResponse", - "description": "Response to a ListFormResponsesRequest.", - "type": "object", - "properties": { - "responses": { - "description": "The returned form responses. Note: The `formId` field is not returned in the `FormResponse` object for list requests.", - "type": "array", - "items": { - "$ref": "FormResponse" - } - }, - "nextPageToken": { - "description": "If set, there are more responses. To get the next page of responses, provide this as `page_token` in a future request.", - "type": "string" - } - } - }, - "BatchUpdateFormRequest": { - "id": "BatchUpdateFormRequest", - "description": "A batch of updates to perform on a form. All the specified updates are made or none of them are.", - "type": "object", - "properties": { - "includeFormInResponse": { - "description": "Whether to return an updated version of the model in the response.", - "type": "boolean" - }, - "requests": { - "description": "Required. The update requests of this batch.", - "type": "array", - "items": { - "$ref": "Request" - } - }, - "writeControl": { - "description": "Provides control over how write requests are executed.", - "$ref": "WriteControl" - } - } - }, - "Request": { - "id": "Request", - "description": "The kinds of update requests that can be made.", - "type": "object", - "properties": { - "updateFormInfo": { - "description": "Update Form's Info.", - "$ref": "UpdateFormInfoRequest" - }, - "updateSettings": { - "description": "Updates the Form's settings.", - "$ref": "UpdateSettingsRequest" - }, - "createItem": { - "description": "Create a new item.", - "$ref": "CreateItemRequest" - }, - "moveItem": { - "description": "Move an item to a specified location.", - "$ref": "MoveItemRequest" - }, - "deleteItem": { - "description": "Delete an item.", - "$ref": "DeleteItemRequest" - }, - "updateItem": { - "description": "Update an item.", - "$ref": "UpdateItemRequest" - } - } - }, - "UpdateFormInfoRequest": { - "id": "UpdateFormInfoRequest", - "description": "Update Form's Info.", - "type": "object", - "properties": { - "info": { - "description": "The info to update.", - "$ref": "Info" - }, - "updateMask": { - "description": "Required. Only values named in this mask are changed. At least one field must be specified. The root `info` is implied and should not be specified. A single `\"*\"` can be used as short-hand for updating every field.", - "type": "string", - "format": "google-fieldmask" - } - } - }, - "UpdateSettingsRequest": { - "id": "UpdateSettingsRequest", - "description": "Update Form's FormSettings.", - "type": "object", - "properties": { - "settings": { - "description": "Required. The settings to update with.", - "$ref": "FormSettings" - }, - "updateMask": { - "description": "Required. Only values named in this mask are changed. At least one field must be specified. The root `settings` is implied and should not be specified. A single `\"*\"` can be used as short-hand for updating every field.", - "type": "string", - "format": "google-fieldmask" - } - } - }, - "CreateItemRequest": { - "id": "CreateItemRequest", - "description": "Create an item in a form.", - "type": "object", - "properties": { - "item": { - "description": "Required. The item to create.", - "$ref": "Item" - }, - "location": { - "description": "Required. Where to place the new item.", - "$ref": "Location" - } - } - }, - "Location": { - "id": "Location", - "description": "A specific location in a form.", - "type": "object", - "properties": { - "index": { - "description": "The index of an item in the form. This must be in the range [0..*N*), where *N* is the number of items in the form.", - "type": "integer", - "format": "int32" - } - } - }, - "MoveItemRequest": { - "id": "MoveItemRequest", - "description": "Move an item in a form.", - "type": "object", - "properties": { - "originalLocation": { - "description": "Required. The location of the item to move.", - "$ref": "Location" - }, - "newLocation": { - "description": "Required. The new location for the item.", - "$ref": "Location" - } - } - }, - "DeleteItemRequest": { - "id": "DeleteItemRequest", - "description": "Delete an item in a form.", - "type": "object", - "properties": { - "location": { - "description": "Required. The location of the item to delete.", - "$ref": "Location" - } - } - }, - "UpdateItemRequest": { - "id": "UpdateItemRequest", - "description": "Update an item in a form.", - "type": "object", - "properties": { - "item": { - "description": "Required. New values for the item. Note that item and question IDs are used if they are provided (and are in the field mask). If an ID is blank (and in the field mask) a new ID is generated. This means you can modify an item by getting the form via forms.get, modifying your local copy of that item to be how you want it, and using UpdateItemRequest to write it back, with the IDs being the same (or not in the field mask).", - "$ref": "Item" - }, - "location": { - "description": "Required. The location identifying the item to update.", - "$ref": "Location" - }, - "updateMask": { - "description": "Required. Only values named in this mask are changed.", - "type": "string", - "format": "google-fieldmask" - } - } - }, - "WriteControl": { - "id": "WriteControl", - "description": "Provides control over how write requests are executed.", - "type": "object", - "properties": { - "requiredRevisionId": { - "description": "The revision ID of the form that the write request is applied to. If this is not the latest revision of the form, the request is not processed and returns a 400 bad request error.", - "type": "string" - }, - "targetRevisionId": { - "description": "The target revision ID of the form that the write request is applied to. If changes have occurred after this revision, the changes in this update request are transformed against those changes. This results in a new revision of the form that incorporates both the changes in the request and the intervening changes, with the server resolving conflicting changes. The target revision ID may only be used to write to recent versions of a form. If the target revision is too far behind the latest revision, the request is not processed and returns a 400 (Bad Request Error). The request may be retried after reading the latest version of the form. In most cases a target revision ID remains valid for several minutes after it is read, but for frequently-edited forms this window may be shorter.", - "type": "string" - } - } - }, - "BatchUpdateFormResponse": { - "id": "BatchUpdateFormResponse", - "description": "Response to a BatchUpdateFormRequest.", - "type": "object", - "properties": { - "form": { - "description": "Based on the bool request field `include_form_in_response`, a form with all applied mutations/updates is returned or not. This may be later than the revision ID created by these changes.", - "$ref": "Form" - }, - "replies": { - "description": "The reply of the updates. This maps 1:1 with the update requests, although replies to some requests may be empty.", - "type": "array", - "items": { - "$ref": "Response" - } - }, - "writeControl": { - "description": "The updated write control after applying the request.", - "$ref": "WriteControl" - } - } - }, - "Response": { - "id": "Response", - "description": "A single response from an update.", - "type": "object", - "properties": { - "createItem": { - "description": "The result of creating an item.", - "$ref": "CreateItemResponse" - } - } - }, - "CreateItemResponse": { - "id": "CreateItemResponse", - "description": "The result of creating an item.", - "type": "object", - "properties": { - "itemId": { - "description": "The ID of the created item.", - "type": "string" - }, - "questionId": { - "description": "The ID of the question created as part of this item, for a question group it lists IDs of all the questions created for this item.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "CreateWatchRequest": { - "id": "CreateWatchRequest", - "description": "Create a new watch.", - "type": "object", - "properties": { - "watch": { - "description": "Required. The watch object. No ID should be set on this object; use `watch_id` instead.", - "$ref": "Watch" - }, - "watchId": { - "description": "The ID to use for the watch. If specified, the ID must not already be in use. If not specified, an ID is generated. This value should be 4-63 characters, and valid characters are /a-z-/.", - "type": "string" - } - } - }, - "Watch": { - "id": "Watch", - "description": "A watch for events for a form. When the designated event happens, a notification will be published to the specified target. The notification's attributes will include a `formId` key that has the ID of the watched form and an `eventType` key that has the string of the type. Messages are sent with at-least-once delivery and are only dropped in extraordinary circumstances. Typically all notifications should be reliably delivered within a few seconds; however, in some situations notifications may be delayed. A watch expires seven days after it is created unless it is renewed with watches.renew", - "type": "object", - "properties": { - "id": { - "description": "Output only. The ID of this watch. See notes on CreateWatchRequest.watch_id.", - "readOnly": true, - "type": "string" - }, - "target": { - "description": "Required. Where to send the notification.", - "$ref": "WatchTarget" - }, - "eventType": { - "description": "Required. Which event type to watch for.", - "type": "string", - "enumDescriptions": [ - "Unspecified event type. This value should not be used.", - "The schema event type. A watch with this event type will be notified about changes to form content and settings.", - "The responses event type. A watch with this event type will be notified when form responses are submitted." - ], - "enum": [ - "EVENT_TYPE_UNSPECIFIED", - "SCHEMA", - "RESPONSES" - ] - }, - "createTime": { - "description": "Output only. Timestamp of when this was created.", - "readOnly": true, - "type": "string", - "format": "google-datetime" - }, - "expireTime": { - "description": "Output only. Timestamp for when this will expire. Each watches.renew call resets this to seven days in the future.", - "readOnly": true, - "type": "string", - "format": "google-datetime" - }, - "errorType": { - "description": "Output only. The most recent error type for an attempted delivery. To begin watching the form again a call can be made to watches.renew which also clears this error information.", - "readOnly": true, - "type": "string", - "enumDescriptions": [ - "Unspecified error type.", - "The cloud project does not have access to the form being watched. This occurs if the user has revoked the authorization for your project to access their form(s). Watches with this error will not be retried. To attempt to begin watching the form again a call can be made to watches.renew", - "The user that granted access no longer has access to the form being watched. Watches with this error will not be retried. To attempt to begin watching the form again a call can be made to watches.renew", - "Another type of error has occurred. Whether notifications will continue depends on the watch state." - ], - "enum": [ - "ERROR_TYPE_UNSPECIFIED", - "PROJECT_NOT_AUTHORIZED", - "NO_USER_ACCESS", - "OTHER_ERRORS" - ] - }, - "state": { - "description": "Output only. The current state of the watch. Additional details about suspended watches can be found by checking the `error_type`.", - "readOnly": true, - "type": "string", - "enumDescriptions": [ - "Unspecified state.", - "Watch is active.", - "The watch is suspended due to an error that may be resolved. The watch will continue to exist until it expires. To attempt to reactivate the watch a call can be made to watches.renew" - ], - "enum": [ - "STATE_UNSPECIFIED", - "ACTIVE", - "SUSPENDED" - ] - } - } - }, - "WatchTarget": { - "id": "WatchTarget", - "description": "The target for notification delivery.", - "type": "object", - "properties": { - "topic": { - "description": "A Pub/Sub topic. To receive notifications, the topic must grant publish privileges to the Forms service account `serviceAccount:forms-notifications@system.gserviceaccount.com`. Only the project that owns a topic may create a watch with it. Pub/Sub delivery guarantees should be considered.", - "$ref": "CloudPubsubTopic" - } - } - }, - "CloudPubsubTopic": { - "id": "CloudPubsubTopic", - "description": "A Pub/Sub topic.", - "type": "object", - "properties": { - "topicName": { - "description": "Required. A fully qualified Pub/Sub topic name to publish the events to. This topic must be owned by the calling project and already exist in Pub/Sub.", - "type": "string" - } - } - }, - "ListWatchesResponse": { - "id": "ListWatchesResponse", - "description": "The response of a ListWatchesRequest.", - "type": "object", - "properties": { - "watches": { - "description": "The returned watches.", - "type": "array", - "items": { - "$ref": "Watch" - } - } - } - }, - "RenewWatchRequest": { - "id": "RenewWatchRequest", - "description": "Renew an existing Watch for seven days.", - "type": "object", - "properties": {} - }, - "Empty": { - "id": "Empty", - "description": "A generic empty message that you can re-use to avoid defining duplicated empty messages in your APIs. A typical example is to use it as the request or the response type of an API method. For instance: service Foo { rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty); }", - "type": "object", - "properties": {} - }, - "SetPublishSettingsRequest": { - "id": "SetPublishSettingsRequest", - "description": "Updates the publish settings of a Form.", - "type": "object", - "properties": { - "publishSettings": { - "description": "Required. The desired publish settings to apply to the form.", - "$ref": "PublishSettings" - }, - "updateMask": { - "description": "Optional. The `publish_settings` fields to update. This field mask accepts the following values: * `publish_state`: Updates or replaces all `publish_state` settings. * `\"*\"`: Updates or replaces all `publish_settings` fields.", - "type": "string", - "format": "google-fieldmask" - } - } - }, - "SetPublishSettingsResponse": { - "id": "SetPublishSettingsResponse", - "description": "The response of a SetPublishSettings request.", - "type": "object", - "properties": { - "formId": { - "description": "Required. The ID of the Form. This is same as the Form.form_id field.", - "type": "string" - }, - "publishSettings": { - "description": "The publish settings of the form.", - "$ref": "PublishSettings" - } - } - } - }, - "mtlsRootUrl": "https://forms.mtls.googleapis.com/", - "id": "forms:v1", - "rootUrl": "https://forms.googleapis.com/" -} diff --git a/campus/common/integration/google/pubsub.json b/campus/common/integration/google/pubsub.json deleted file mode 100644 index 86a0209f..00000000 --- a/campus/common/integration/google/pubsub.json +++ /dev/null @@ -1,870 +0,0 @@ -{ - "id": "pubsub:v1beta1a", - "auth": { - "oauth2": { - "scopes": { - "https://www.googleapis.com/auth/cloud-platform": { - "description": "See, edit, configure, and delete your Google Cloud data and see the email address for your Google Account." - }, - "https://www.googleapis.com/auth/pubsub": { - "description": "View and manage Pub/Sub topics and subscriptions" - } - } - } - }, - "rootUrl": "https://pubsub.googleapis.com/", - "batchPath": "batch", - "title": "Cloud Pub/Sub API", - "resources": { - "topics": { - "methods": { - "create": { - "id": "pubsub.topics.create", - "path": "v1beta1a/topics", - "flatPath": "v1beta1a/topics", - "httpMethod": "POST", - "parameters": {}, - "parameterOrder": [], - "request": { - "$ref": "Topic" - }, - "response": { - "$ref": "Topic" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Creates the given topic with the given name." - }, - "publish": { - "id": "pubsub.topics.publish", - "path": "v1beta1a/topics/publish", - "flatPath": "v1beta1a/topics/publish", - "httpMethod": "POST", - "parameters": {}, - "parameterOrder": [], - "request": { - "$ref": "PublishRequest" - }, - "response": { - "$ref": "Empty" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Adds a message to the topic. Returns NOT_FOUND if the topic does not exist." - }, - "publishBatch": { - "id": "pubsub.topics.publishBatch", - "path": "v1beta1a/topics/publishBatch", - "flatPath": "v1beta1a/topics/publishBatch", - "httpMethod": "POST", - "parameters": {}, - "parameterOrder": [], - "request": { - "$ref": "PublishBatchRequest" - }, - "response": { - "$ref": "PublishBatchResponse" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Adds one or more messages to the topic. Returns NOT_FOUND if the topic does not exist." - }, - "get": { - "id": "pubsub.topics.get", - "path": "v1beta1a/topics/{+topic}", - "flatPath": "v1beta1a/topics/{topicsId}", - "httpMethod": "GET", - "parameters": { - "topic": { - "description": "The name of the topic to get.", - "pattern": "^.*$", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "topic" - ], - "response": { - "$ref": "Topic" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Gets the configuration of a topic. Since the topic only has the name attribute, this method is only useful to check the existence of a topic. If other attributes are added in the future, they will be returned here." - }, - "list": { - "id": "pubsub.topics.list", - "path": "v1beta1a/topics", - "flatPath": "v1beta1a/topics", - "httpMethod": "GET", - "parameters": { - "query": { - "description": "A valid label query expression.", - "location": "query", - "type": "string" - }, - "maxResults": { - "description": "Maximum number of topics to return.", - "location": "query", - "type": "integer", - "format": "int32" - }, - "pageToken": { - "description": "The value obtained in the last ListTopicsResponse for continuation.", - "location": "query", - "type": "string" - } - }, - "parameterOrder": [], - "response": { - "$ref": "ListTopicsResponse" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Lists matching topics." - }, - "delete": { - "id": "pubsub.topics.delete", - "path": "v1beta1a/topics/{+topic}", - "flatPath": "v1beta1a/topics/{topicsId}", - "httpMethod": "DELETE", - "parameters": { - "topic": { - "description": "Name of the topic to delete.", - "pattern": "^.*$", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "topic" - ], - "response": { - "$ref": "Empty" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Deletes the topic with the given name. Returns NOT_FOUND if the topic does not exist. After a topic is deleted, a new topic may be created with the same name." - } - } - }, - "subscriptions": { - "methods": { - "create": { - "id": "pubsub.subscriptions.create", - "path": "v1beta1a/subscriptions", - "flatPath": "v1beta1a/subscriptions", - "httpMethod": "POST", - "parameters": {}, - "parameterOrder": [], - "request": { - "$ref": "Subscription" - }, - "response": { - "$ref": "Subscription" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Creates a subscription on a given topic for a given subscriber. If the subscription already exists, returns ALREADY_EXISTS. If the corresponding topic doesn't exist, returns NOT_FOUND. If the name is not provided in the request, the server will assign a random name for this subscription on the same project as the topic." - }, - "get": { - "id": "pubsub.subscriptions.get", - "path": "v1beta1a/subscriptions/{+subscription}", - "flatPath": "v1beta1a/subscriptions/{subscriptionsId}", - "httpMethod": "GET", - "parameters": { - "subscription": { - "description": "The name of the subscription to get.", - "pattern": "^.*$", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "subscription" - ], - "response": { - "$ref": "Subscription" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Gets the configuration details of a subscription." - }, - "list": { - "id": "pubsub.subscriptions.list", - "path": "v1beta1a/subscriptions", - "flatPath": "v1beta1a/subscriptions", - "httpMethod": "GET", - "parameters": { - "query": { - "description": "A valid label query expression.", - "location": "query", - "type": "string" - }, - "maxResults": { - "description": "Maximum number of subscriptions to return.", - "location": "query", - "type": "integer", - "format": "int32" - }, - "pageToken": { - "description": "The value obtained in the last ListSubscriptionsResponse for continuation.", - "location": "query", - "type": "string" - } - }, - "parameterOrder": [], - "response": { - "$ref": "ListSubscriptionsResponse" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Lists matching subscriptions." - }, - "delete": { - "id": "pubsub.subscriptions.delete", - "path": "v1beta1a/subscriptions/{+subscription}", - "flatPath": "v1beta1a/subscriptions/{subscriptionsId}", - "httpMethod": "DELETE", - "parameters": { - "subscription": { - "description": "The subscription to delete.", - "pattern": "^.*$", - "location": "path", - "required": true, - "type": "string" - } - }, - "parameterOrder": [ - "subscription" - ], - "response": { - "$ref": "Empty" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Deletes an existing subscription. All pending messages in the subscription are immediately dropped. Calls to Pull after deletion will return NOT_FOUND." - }, - "modifyPushConfig": { - "id": "pubsub.subscriptions.modifyPushConfig", - "path": "v1beta1a/subscriptions/modifyPushConfig", - "flatPath": "v1beta1a/subscriptions/modifyPushConfig", - "httpMethod": "POST", - "parameters": {}, - "parameterOrder": [], - "request": { - "$ref": "ModifyPushConfigRequest" - }, - "response": { - "$ref": "Empty" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Modifies the PushConfig for a specified subscription. This method can be used to suspend the flow of messages to an endpoint by clearing the PushConfig field in the request. Messages will be accumulated for delivery even if no push configuration is defined or while the configuration is modified." - }, - "pull": { - "id": "pubsub.subscriptions.pull", - "path": "v1beta1a/subscriptions/pull", - "flatPath": "v1beta1a/subscriptions/pull", - "httpMethod": "POST", - "parameters": {}, - "parameterOrder": [], - "request": { - "$ref": "PullRequest" - }, - "response": { - "$ref": "PullResponse" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Pulls a single message from the server. If return_immediately is true, and no messages are available in the subscription, this method returns FAILED_PRECONDITION. The system is free to return an UNAVAILABLE error if no messages are available in a reasonable amount of time (to reduce system load)." - }, - "pullBatch": { - "id": "pubsub.subscriptions.pullBatch", - "path": "v1beta1a/subscriptions/pullBatch", - "flatPath": "v1beta1a/subscriptions/pullBatch", - "httpMethod": "POST", - "parameters": {}, - "parameterOrder": [], - "request": { - "$ref": "PullBatchRequest" - }, - "response": { - "$ref": "PullBatchResponse" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Pulls messages from the server. Returns an empty list if there are no messages available in the backlog. The system is free to return UNAVAILABLE if there are too many pull requests outstanding for the given subscription." - }, - "modifyAckDeadline": { - "id": "pubsub.subscriptions.modifyAckDeadline", - "path": "v1beta1a/subscriptions/modifyAckDeadline", - "flatPath": "v1beta1a/subscriptions/modifyAckDeadline", - "httpMethod": "POST", - "parameters": {}, - "parameterOrder": [], - "request": { - "$ref": "ModifyAckDeadlineRequest" - }, - "response": { - "$ref": "Empty" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Modifies the Ack deadline for a message received from a pull request." - }, - "acknowledge": { - "id": "pubsub.subscriptions.acknowledge", - "path": "v1beta1a/subscriptions/acknowledge", - "flatPath": "v1beta1a/subscriptions/acknowledge", - "httpMethod": "POST", - "parameters": {}, - "parameterOrder": [], - "request": { - "$ref": "AcknowledgeRequest" - }, - "response": { - "$ref": "Empty" - }, - "scopes": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/pubsub" - ], - "description": "Acknowledges a particular received message: the Pub/Sub system can remove the given message from the subscription. Acknowledging a message whose Ack deadline has expired may succeed, but the message could have been already redelivered. Acknowledging a message more than once will not result in an error. This is only used for messages received via pull." - } - } - } - }, - "protocol": "rest", - "revision": "20250617", - "documentationLink": "https://cloud.google.com/pubsub/docs", - "name": "pubsub", - "servicePath": "", - "icons": { - "x16": "http://www.google.com/images/icons/product/search-16.gif", - "x32": "http://www.google.com/images/icons/product/search-32.gif" - }, - "version": "v1beta1a", - "discoveryVersion": "v1", - "kind": "discovery#restDescription", - "parameters": { - "access_token": { - "type": "string", - "description": "OAuth access token.", - "location": "query" - }, - "alt": { - "type": "string", - "description": "Data format for response.", - "default": "json", - "enum": [ - "json", - "media", - "proto" - ], - "enumDescriptions": [ - "Responses with Content-Type of application/json", - "Media download with context-dependent Content-Type", - "Responses with Content-Type of application/x-protobuf" - ], - "location": "query" - }, - "callback": { - "type": "string", - "description": "JSONP", - "location": "query" - }, - "fields": { - "type": "string", - "description": "Selector specifying which fields to include in a partial response.", - "location": "query" - }, - "key": { - "type": "string", - "description": "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.", - "location": "query" - }, - "oauth_token": { - "type": "string", - "description": "OAuth 2.0 token for the current user.", - "location": "query" - }, - "prettyPrint": { - "type": "boolean", - "description": "Returns response with indentations and line breaks.", - "default": "true", - "location": "query" - }, - "quotaUser": { - "type": "string", - "description": "Available to use for quota purposes for server-side applications. Can be any arbitrary string assigned to a user, but should not exceed 40 characters.", - "location": "query" - }, - "upload_protocol": { - "type": "string", - "description": "Upload protocol for media (e.g. \"raw\", \"multipart\").", - "location": "query" - }, - "uploadType": { - "type": "string", - "description": "Legacy upload protocol for media (e.g. \"media\", \"multipart\").", - "location": "query" - }, - "$.xgafv": { - "type": "string", - "description": "V1 error format.", - "enum": [ - "1", - "2" - ], - "enumDescriptions": [ - "v1 error format", - "v2 error format" - ], - "location": "query" - } - }, - "basePath": "", - "baseUrl": "https://pubsub.googleapis.com/", - "ownerName": "Google", - "ownerDomain": "google.com", - "endpoints": [ - { - "endpointUrl": "https://pubsub.me-central2.rep.googleapis.com/", - "location": "me-central2", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.europe-west3.rep.googleapis.com/", - "location": "europe-west3", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.europe-west8.rep.googleapis.com/", - "location": "europe-west8", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.europe-west9.rep.googleapis.com/", - "location": "europe-west9", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.us-central1.rep.googleapis.com/", - "location": "us-central1", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.us-central2.rep.googleapis.com/", - "location": "us-central2", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.us-east1.rep.googleapis.com/", - "location": "us-east1", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.us-east4.rep.googleapis.com/", - "location": "us-east4", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.us-east5.rep.googleapis.com/", - "location": "us-east5", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.us-south1.rep.googleapis.com/", - "location": "us-south1", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.us-west1.rep.googleapis.com/", - "location": "us-west1", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.us-west2.rep.googleapis.com/", - "location": "us-west2", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.us-west3.rep.googleapis.com/", - "location": "us-west3", - "description": "Regional Endpoint" - }, - { - "endpointUrl": "https://pubsub.us-west4.rep.googleapis.com/", - "location": "us-west4", - "description": "Regional Endpoint" - } - ], - "canonicalName": "Pubsub", - "mtlsRootUrl": "https://pubsub.mtls.googleapis.com/", - "schemas": { - "Topic": { - "id": "Topic", - "description": "A topic resource.", - "type": "object", - "properties": { - "name": { - "description": "Name of the topic.", - "type": "string" - } - } - }, - "PublishRequest": { - "id": "PublishRequest", - "description": "Request for the Publish method.", - "type": "object", - "properties": { - "topic": { - "description": "The message in the request will be published on this topic.", - "type": "string" - }, - "message": { - "description": "The message to publish.", - "$ref": "PubsubMessage" - } - } - }, - "PubsubMessage": { - "id": "PubsubMessage", - "description": "A message data and its labels.", - "type": "object", - "properties": { - "data": { - "description": "The message payload.", - "type": "string", - "format": "byte" - }, - "label": { - "description": "Optional list of labels for this message. Keys in this collection must be unique.", - "type": "array", - "items": { - "$ref": "Label" - } - }, - "messageId": { - "description": "ID of this message assigned by the server at publication time. Guaranteed to be unique within the topic. This value may be read by a subscriber that receives a PubsubMessage via a Pull call or a push delivery. It must not be populated by a publisher in a Publish call.", - "type": "string" - }, - "publishTime": { - "description": "The time at which the message was published. The time is milliseconds since the UNIX epoch.", - "type": "string", - "format": "int64" - } - } - }, - "Label": { - "id": "Label", - "description": "A key-value pair applied to a given object.", - "type": "object", - "properties": { - "key": { - "description": "The key of a label is a syntactically valid URL (as per RFC 1738) with the \"scheme\" and initial slashes omitted and with the additional restrictions noted below. Each key should be globally unique. The \"host\" portion is called the \"namespace\" and is not necessarily resolvable to a network endpoint. Instead, the namespace indicates what system or entity defines the semantics of the label. Namespaces do not restrict the set of objects to which a label may be associated. Keys are defined by the following grammar: key = hostname \"/\" kpath kpath = ksegment *[ \"/\" ksegment ] ksegment = alphadigit | *[ alphadigit | \"-\" | \"_\" | \".\" ] where \"hostname\" and \"alphadigit\" are defined as in RFC 1738. Example key: spanner.google.com/universe", - "type": "string" - }, - "strValue": { - "description": "A string value.", - "type": "string" - }, - "numValue": { - "description": "An integer value.", - "type": "string", - "format": "int64" - } - } - }, - "Empty": { - "id": "Empty", - "description": "An empty message that you can re-use to avoid defining duplicated empty messages in your project. A typical example is to use it as argument or the return value of a service API. For instance: service Foo { rpc Bar (proto2.Empty) returns (proto2.Empty) { }; }; BEGIN GOOGLE-INTERNAL The difference between this one and net/rpc/empty-message.proto is that 1) The generated message here is in proto2 C++ API. 2) The proto2.Empty has minimum dependencies (no message_set or net/rpc dependencies) END GOOGLE-INTERNAL", - "type": "object", - "properties": {} - }, - "PublishBatchRequest": { - "id": "PublishBatchRequest", - "description": "Request for the PublishBatch method.", - "type": "object", - "properties": { - "topic": { - "description": "The messages in the request will be published on this topic.", - "type": "string" - }, - "messages": { - "description": "The messages to publish.", - "type": "array", - "items": { - "$ref": "PubsubMessage" - } - } - } - }, - "PublishBatchResponse": { - "id": "PublishBatchResponse", - "description": "Response for the PublishBatch method.", - "type": "object", - "properties": { - "messageIds": { - "description": "The server-assigned ID of each published message, in the same order as the messages in the request. IDs are guaranteed to be unique within the topic.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "ListTopicsResponse": { - "id": "ListTopicsResponse", - "description": "Response for the ListTopics method.", - "type": "object", - "properties": { - "topic": { - "description": "The resulting topics.", - "type": "array", - "items": { - "$ref": "Topic" - } - }, - "nextPageToken": { - "description": "If not empty, indicates that there are more topics that match the request, and this value should be passed to the next ListTopicsRequest to continue.", - "type": "string" - } - } - }, - "Subscription": { - "id": "Subscription", - "description": "A subscription resource.", - "type": "object", - "properties": { - "name": { - "description": "Name of the subscription.", - "type": "string" - }, - "topic": { - "description": "The name of the topic from which this subscription is receiving messages.", - "type": "string" - }, - "pushConfig": { - "description": "If push delivery is used with this subscription, this field is used to configure it.", - "$ref": "PushConfig" - }, - "ackDeadlineSeconds": { - "description": "For either push or pull delivery, the value is the maximum time after a subscriber receives a message before the subscriber should acknowledge or Nack the message. If the Ack deadline for a message passes without an Ack or a Nack, the Pub/Sub system will eventually redeliver the message. If a subscriber acknowledges after the deadline, the Pub/Sub system may accept the Ack, but it is possible that the message has been already delivered again. Multiple Acks to the message are allowed and will succeed. For push delivery, this value is used to set the request timeout for the call to the push endpoint. For pull delivery, this value is used as the initial value for the Ack deadline. It may be overridden for each message using its corresponding ack_id with ModifyAckDeadline. While a message is outstanding (i.e. it has been delivered to a pull subscriber and the subscriber has not yet Acked or Nacked), the Pub/Sub system will not deliver that message to another pull subscriber (on a best-effort basis).", - "type": "integer", - "format": "int32" - } - } - }, - "PushConfig": { - "id": "PushConfig", - "description": "Configuration for a push delivery endpoint.", - "type": "object", - "properties": { - "pushEndpoint": { - "description": "A URL locating the endpoint to which messages should be pushed. For example, a Webhook endpoint might use \"https://example.com/push\".", - "type": "string" - } - } - }, - "ListSubscriptionsResponse": { - "id": "ListSubscriptionsResponse", - "description": "Response for the ListSubscriptions method.", - "type": "object", - "properties": { - "subscription": { - "description": "The subscriptions that match the request.", - "type": "array", - "items": { - "$ref": "Subscription" - } - }, - "nextPageToken": { - "description": "If not empty, indicates that there are more subscriptions that match the request and this value should be passed to the next ListSubscriptionsRequest to continue.", - "type": "string" - } - } - }, - "ModifyPushConfigRequest": { - "id": "ModifyPushConfigRequest", - "description": "Request for the ModifyPushConfig method.", - "type": "object", - "properties": { - "subscription": { - "description": "The name of the subscription.", - "type": "string" - }, - "pushConfig": { - "description": "An empty push_config indicates that the Pub/Sub system should pause pushing messages from the given subscription.", - "$ref": "PushConfig" - } - } - }, - "PullRequest": { - "id": "PullRequest", - "description": "Request for the Pull method.", - "type": "object", - "properties": { - "subscription": { - "description": "The subscription from which a message should be pulled.", - "type": "string" - }, - "returnImmediately": { - "description": "If this is specified as true the system will respond immediately even if it is not able to return a message in the Pull response. Otherwise the system is allowed to wait until at least one message is available rather than returning FAILED_PRECONDITION. The client may cancel the request if it does not wish to wait any longer for the response.", - "type": "boolean" - } - } - }, - "PullResponse": { - "id": "PullResponse", - "description": "Either a PubsubMessage or a truncation event. One of these two must be populated.", - "type": "object", - "properties": { - "ackId": { - "description": "This ID must be used to acknowledge the received event or message.", - "type": "string" - }, - "pubsubEvent": { - "description": "A pubsub message or truncation event.", - "$ref": "PubsubEvent" - } - } - }, - "PubsubEvent": { - "id": "PubsubEvent", - "description": "An event indicating a received message or truncation event.", - "type": "object", - "properties": { - "subscription": { - "description": "The subscription that received the event.", - "type": "string" - }, - "message": { - "description": "A received message.", - "$ref": "PubsubMessage" - }, - "truncated": { - "description": "Indicates that this subscription has been truncated.", - "type": "boolean" - }, - "deleted": { - "description": "Indicates that this subscription has been deleted. (Note that pull subscribers will always receive NOT_FOUND in response in their pull request on the subscription, rather than seeing this boolean.)", - "type": "boolean" - } - } - }, - "PullBatchRequest": { - "id": "PullBatchRequest", - "description": "Request for the PullBatch method.", - "type": "object", - "properties": { - "subscription": { - "description": "The subscription from which messages should be pulled.", - "type": "string" - }, - "returnImmediately": { - "description": "If this is specified as true the system will respond immediately even if it is not able to return a message in the Pull response. Otherwise the system is allowed to wait until at least one message is available rather than returning no messages. The client may cancel the request if it does not wish to wait any longer for the response.", - "type": "boolean" - }, - "maxEvents": { - "description": "The maximum number of PubsubEvents returned for this request. The Pub/Sub system may return fewer than the number of events specified.", - "type": "integer", - "format": "int32" - } - } - }, - "PullBatchResponse": { - "id": "PullBatchResponse", - "description": "Response for the PullBatch method.", - "type": "object", - "properties": { - "pullResponses": { - "description": "Received Pub/Sub messages or status events. The Pub/Sub system will return zero messages if there are no more messages available in the backlog. The Pub/Sub system may return fewer than the max_events requested even if there are more messages available in the backlog.", - "type": "array", - "items": { - "$ref": "PullResponse" - } - } - } - }, - "ModifyAckDeadlineRequest": { - "id": "ModifyAckDeadlineRequest", - "description": "Request for the ModifyAckDeadline method.", - "type": "object", - "properties": { - "subscription": { - "description": "Next Index: 5 The name of the subscription from which messages are being pulled.", - "type": "string" - }, - "ackId": { - "description": "The acknowledgment ID. Either this or ack_ids must be populated, not both.", - "deprecated": true, - "type": "string" - }, - "ackIds": { - "description": "List of acknowledgment IDs. Either this field or ack_id should be populated, not both.", - "type": "array", - "items": { - "type": "string" - } - }, - "ackDeadlineSeconds": { - "description": "The new ack deadline with respect to the time this request was sent to the Pub/Sub system. Must be \u003e= 0. For example, if the value is 10, the new ack deadline will expire 10 seconds after the ModifyAckDeadline call was made. Specifying zero may immediately make the message available for another pull request.", - "type": "integer", - "format": "int32" - } - } - }, - "AcknowledgeRequest": { - "id": "AcknowledgeRequest", - "description": "Request for the Acknowledge method.", - "type": "object", - "properties": { - "subscription": { - "description": "The subscription whose message is being acknowledged.", - "type": "string" - }, - "ackId": { - "description": "The acknowledgment ID for the message being acknowledged. This was returned by the Pub/Sub system in the Pull response.", - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "description": "Provides reliable, many-to-many, asynchronous messaging between applications. " -} diff --git a/campus/common/integration/schema.py b/campus/common/integration/schema.py deleted file mode 100644 index 07a9e527..00000000 --- a/campus/common/integration/schema.py +++ /dev/null @@ -1,44 +0,0 @@ -"""apps.common.models.integration.config.schema - -Schema to describe the JSON files describing third-party integration -configurations. -""" - -from typing import Literal, NotRequired, TypedDict - -from campus.common.devops import Env - -HttpScheme = Literal["basic", "bearer"] -OAuth2Flow = Literal["authorizationCode", "clientCredentials", "implicit", "password"] -Security = Literal["http", "apiKey", "oauth2", "openIdConnect"] - -Url = str - - -class SecurityConfigSchema(TypedDict): - """Schema for security configuration.""" - security_scheme: Security - - -class OAuth2AuthorizationCodeConfigSchema(SecurityConfigSchema): - """Schema for OAuth2 security configuration.""" - flow: str - scopes: list[str] - authorization_url: Url - token_url: Url - headers: NotRequired[dict[str, str]] - user_info_url: Url - extra_params: NotRequired[dict[str, str]] - token_params: NotRequired[dict[str, str]] - user_info_params: NotRequired[dict[str, str]] - - -class IntegrationConfigSchema(TypedDict): - """Schema for integration configuration.""" - provider: str - description: str - servers: dict[Env, Url] - redirect_uri: str - api_doc: Url - discovery_url: Url - security: dict[Security, SecurityConfigSchema] diff --git a/campus/common/validation/flask.py b/campus/common/validation/flask.py deleted file mode 100644 index ea67d5ba..00000000 --- a/campus/common/validation/flask.py +++ /dev/null @@ -1,220 +0,0 @@ -"""campus.common.validation.flask - -Common utility functions for validation of flask requests and responses. -""" - -from functools import wraps -from json import JSONDecodeError -from typing import ( - Any, - Callable, - Generic, - Mapping, - NoReturn, - NotRequired, - Protocol, - Required, - Type, - TypeVar -) - -from flask import has_request_context, request as flask_request -from werkzeug.wrappers import Response as FlaskResponse - -from campus.common.validation import record - -R = TypeVar("R", covariant=True) -# Only expecting strings or dicts -JsonObject = dict[str, Any] -StatusCode = int -ViewFunctionDecorator = Callable[["ViewFunction"], "ViewFunction"] -# Actually, view functions may return a variety of return values which Flask is -# able to handle -# But Campus API sticks to JSON-serializable return values, with a status code -JsonResponse = tuple[dict[str, Any], StatusCode] -HtmlResponse = tuple[str, StatusCode] - - -class ErrorHandler(Protocol): - """Define an ErrorHandler as a function that takes a status code and - optional keyword arguments. - - Error Handlers must raise an exception. - """ - def __call__(self, status: StatusCode, **body) -> NoReturn: - """An error handler returns None""" - ... - - -class ViewFunction(Protocol, Generic[R]): - """A view function that takes arbitrary arguments and returns a response. - """ - def __call__(self, *args: str, **kwargs) -> R: - ... - - -JsonViewFunction = ViewFunction[JsonResponse] -FlaskViewFunction = ViewFunction[FlaskResponse] - - -def get_request_urlparams() -> JsonObject: - """Get the JSON body of the current Flask request.""" - if not has_request_context(): - raise RuntimeError("Request context not available") - params = dict(flask_request.args or {}) - return params - - -def unpack_request_urlparams(vf: ViewFunction[R]) -> ViewFunction[R]: - """Unpacks the request URL parameters into the view function.""" - @wraps(vf) - def unpackedvf(*args: str, **kwargs) -> R: - """The decorated ViewFunction that unpacks the request URL parameters - into the inner view-function. - """ - # Unpack URL parameters into kwargs - kwargs.update(get_request_urlparams()) - return vf(*args, **kwargs) - return unpackedvf - - -def get_request_json() -> JsonObject: - """Get the JSON body of the current Flask request.""" - if not has_request_context(): - raise RuntimeError("Request context not available") - payload = flask_request.get_json(silent=True) - if payload is None: - raise JSONDecodeError("Invalid JSON", "", 0) - return payload - - -def unpack_request_json(vf: ViewFunction) -> ViewFunction: - """Unpacks the request JSON body into the view function.""" - @wraps(vf) - def unpackedvf(*args: str, **kwargs) -> JsonResponse: - """The decorated ViewFunction that unpacks the request JSON body into - the inner view-function. - """ - payload = get_request_json() - kwargs.update(payload) - return vf(*args, **kwargs) - return unpackedvf - - -def validate_request_and_extract_json( - schema: Mapping[str, Type], *, - on_error: ErrorHandler, -) -> JsonObject: - """Validate the request JSON body against the provided schema before - returning the payload. - """ - try: - payload = get_request_json() - record.validate_keys( - payload, - schema, - ) - except (KeyError, TypeError, JSONDecodeError) as err: - on_error(400, message=err.args[0]) - else: - return payload - - -def validate_request_and_extract_urlparams( - schema: Mapping[str, Type], *, - on_error: ErrorHandler, - ignore_extra: bool = False, -) -> JsonObject: - """Validate the request URL parameters against the provided schema before - returning the parameters. - """ - try: - params = get_request_urlparams() - record.validate_keys(params, schema, ignore_extra=ignore_extra) - except (KeyError, TypeError) as err: - on_error(400, message=err.args[0]) - else: - return params - - -def validate_json_response( - schema: Mapping[str, Type], - resp_json: Mapping[str, Any], *, - on_error: ErrorHandler, - ignore_extra: bool = True, - error_status_code: StatusCode = 500, - error_message: str | None = None, -) -> None: - """Validate the response JSON body against the provided schema.""" - if resp_json is None: - on_error(500, message="Response body must be a JSON object") - return - try: - record.validate_keys(resp_json, schema, ignore_extra=ignore_extra) - except (KeyError, TypeError) as err: - on_error(error_status_code, message=error_message or err.args[0]) - - -def validate( - *, - request: Mapping[str, Type] | None = None, - response: Mapping[str, Type] | None = None, - on_error: ErrorHandler, -) -> ViewFunctionDecorator: - """Returns a decorator that takes a view-function and returns a - validated-view-function. - - The validated-view-function only takes positional arguments, passing them - to the wrapped view-function. - The validated-view-function will unpack the request JSON body and pass it - to the wrapped view-function as keyword arguments. - - An error handler must be provided, which will be called with the status - code and any additional keyword arguments. - The error handler must raise an exception. - """ - - def vfdecorator(vf: ViewFunction) -> ViewFunction: - """Validates the current Flask request JSON body, and unpacks it into - the wrapped view-function. - Validates the response JSON body before returning. - """ - # TODO: provide helpful validation hints - @wraps(vf) - def validatedvf(*args: str, **payload) -> JsonResponse: - """The decorated ValidatedViewFunction that unpacks the response - JSON body into the inner view-function. - """ - # Validate request body - if request is not None: - try: - record.validate_keys( - payload, - request, - ignore_extra=True, - required=True, - ) - except (KeyError, TypeError): - on_error(400) - - except Exception: - on_error(500) - # Call view function - resp_json, status_code = vf(*args, **payload) - assert isinstance(resp_json, dict), "Response body must be a JSON object" - # Validate response body - if response is not None and 200 <= status_code < 300: - try: - record.validate_keys( - resp_json, - response, - ignore_extra=True, - required=True, - ) - except (KeyError, TypeError): - on_error(500) - except Exception: - on_error(500) - return resp_json, status_code - return validatedvf - return vfdecorator diff --git a/campus/common/validation/name.py b/campus/common/validation/name.py deleted file mode 100644 index c505f0cf..00000000 --- a/campus/common/validation/name.py +++ /dev/null @@ -1,32 +0,0 @@ -"""campus.common.validation.name - -Common functions used for validation of names and labels. -""" - -def is_valid_identifier(name: str) -> bool: - """ - Check if the given name is a valid Python identifier. - """ - return name.isidentifier() - -def is_valid_label(name: str) -> bool: - """Check if the given name is a valid label. - - A label: - - must start with a letter or underscore - - can contain letters, digits, underscores, hyphens - - cannot be empty - - cannot contain spaces or special characters - - cannot be longer than 64 characters - """ - if not name: - return False - if len(name) > 64: - return False - if name[0] != '_' and not name[0].isalpha(): - return False - for c in name[1:]: - if c not in ('-', '_') and not c.isalnum(): - return False - return True - diff --git a/campus/common/webauth/header.py b/campus/common/webauth/header.py deleted file mode 100644 index be8daba1..00000000 --- a/campus/common/webauth/header.py +++ /dev/null @@ -1,80 +0,0 @@ -"""campus.common.webauth.header - -Utility functions and classes for handling HTTP headers -""" - -__all__ = [ - "HttpAuthProperty", - "HttpHeaderDict", -] - -from base64 import b64decode, b64encode - - -class HttpAuthProperty(str): - """Authentication property for HTTP headers.""" - def __new__(cls, value: str): - if not isinstance(value, str): - raise TypeError("BasicAuthProperty must be a string") - if not value.startswith("Basic ") and not value.startswith("Bearer "): - raise ValueError( - "Authorization header must start with 'Basic ' or 'Bearer '" - ) - return str.__new__(cls, value) - - @property - def scheme(self) -> str: - """Return the authentication scheme.""" - scheme, _ = self.split(" ", 1) - return scheme.lower() - - @property - def value(self) -> str: - """Return the value for the HTTP header.""" - _, key = self.split(" ", 1) - return key.strip() - - def credentials(self, sep: str = ":") -> tuple[str, ...]: - """Decode Base64-encoded credentials.""" - if self.scheme != "basic": - raise ValueError("Only Basic authentication can be decoded") - decoded = b64decode(self.value).decode("utf-8") - assert sep in decoded, ( - f"Credentials must contain '{sep}' separator, got: {decoded}" - ) - return tuple(decoded.split(sep)) - - @classmethod - def from_credentials(cls, c_id: str, c_secret: str) -> "HttpAuthProperty": - """Create an HttpAuthProperty from client credentials.""" - credentials = f"{c_id}:{c_secret}" - encoded_credentials = b64encode(credentials.encode()).decode() - return cls(f"Basic {encoded_credentials}") - - @classmethod - def from_bearer_token(cls, token: str) -> "HttpAuthProperty": - """Create an HttpAuthProperty from a bearer token.""" - return cls(f"Bearer {token}") - - -class HttpHeaderDict(dict): - """HTTP header representation as a dictionary.""" - - def get_auth(self) -> HttpAuthProperty | None: - """Get the authentication property from the header.""" - auth_header = self.get("Authorization") - if auth_header: - return HttpAuthProperty(auth_header) - return None - - @classmethod - def from_credentials(cls, c_id: str, c_secret: str) -> "HttpHeaderDict": - """Create an HTTP header dictionary from client credentials.""" - auth_property = HttpAuthProperty.from_credentials(c_id, c_secret) - return cls({"Authorization": auth_property}) - - @classmethod - def from_bearer_token(cls, token: str) -> "HttpHeaderDict": - """Create an HTTP header dictionary from a bearer token.""" - auth_property = HttpAuthProperty.from_bearer_token(token) - return cls({"Authorization": auth_property}) diff --git a/campus/common/webauth/http.py b/campus/common/webauth/http.py deleted file mode 100644 index ff4590f8..00000000 --- a/campus/common/webauth/http.py +++ /dev/null @@ -1,86 +0,0 @@ -"""campus.common.webauth.http - -HTTP Authentication configs and models. - -The HTTP authentication scheme comprises two types of authentication: -1. Basic Authentication: Uses a client_id and client_secret encoded in Base64. -2. Bearer Authentication: Uses a token (e.g., JWT) in the Authorization header -""" - -__all__ = [ - "HttpAuthConfigSchema", - "HttpAuthenticationScheme", - "HttpScheme", - "HttpSecurityError", -] - -from typing import Literal, Unpack - -from campus.common.errors import api_errors -from campus.common.webauth.header import HttpAuthProperty, HttpHeaderDict -from campus.common.integration.config import SecurityConfigSchema - -from .base import SecurityError, SecurityScheme - -HttpScheme = Literal["basic", "bearer"] - - -class HttpSecurityError(SecurityError): - """HTTP authentication error.""" - - -class HttpAuthConfigSchema(SecurityConfigSchema): - """HTTP authentication scheme schema.""" - scheme: HttpScheme - - -class HttpAuthenticationScheme(SecurityScheme): - """HTTP authentication for Basic and Bearer schemes. - - This class provides methods to: - - retrieve the authentication credentials from an HTTP header - - validate the credentials against the configured scheme - """ - scheme: HttpScheme - - def __init__( - self, - provider: str, - **config: Unpack[HttpAuthConfigSchema] - ): - super().__init__(provider, **config) - self.scheme = config["scheme"] - - def get_auth(self, *, header: dict) -> HttpAuthProperty: - """Validate the HTTP header for authentication. - - Raises an API error if the header is invalid or missing. - - Returns: - HttpHeaderDict: The HTTP header dictionary containing the - authentication information. - """ - auth = HttpHeaderDict(header).get_auth() - if auth is None: - api_errors.raise_api_error(401) - if auth.scheme != self.scheme: - api_errors.raise_api_error(401) - return auth - - @classmethod - def from_header( - cls, - *, - provider: str, - header: dict - ) -> "HttpAuthenticationScheme": - """Create an HTTP authentication scheme from an HTTP header.""" - auth = HttpHeaderDict(header).get_auth() - if auth is None: - api_errors.raise_api_error(401) - match auth.scheme: - case "basic": - return cls(provider, security_scheme="http", scheme="basic") - case "bearer": - return cls(provider, security_scheme="http", scheme="bearer") - raise HttpSecurityError(f"Unsupported HTTP scheme: {auth.scheme}") diff --git a/campus/common/webauth/token.py b/campus/common/webauth/token.py deleted file mode 100644 index 52b80cb8..00000000 --- a/campus/common/webauth/token.py +++ /dev/null @@ -1,119 +0,0 @@ -"""campus.common.webauth.token - -Token management schemas and models -""" - -from typing import NotRequired, TypedDict, Unpack - -from campus.common import schema -from campus.common.utils import utc_time - -EXPIRY_THRESHOLD = 300 # 5 minutes in seconds, used to check token expiry - - -class TokenResponseSchema(TypedDict): - """Response schema for access token exchange.""" - access_token: str # Access token issued by the OAuth2 provider - token_type: str # Type of the token (e.g., "Bearer") - expires_in: int # Lifetime of the access token in seconds - scope: str # Scopes granted by the access token - # Optional refresh token for long-lived sessions - refresh_token: NotRequired[str] - # Lifetime of the refresh token in seconds - refresh_token_expires_in: NotRequired[int] - - -class TokenSchema(TypedDict): - """Schema for token storage.""" - token_type: str - access_token: str - expires_at: schema.DateTime - scopes: list[str] - refresh_token: NotRequired[str] - refresh_token_expires_at: NotRequired[schema.DateTime] - - -class CredentialToken: - """Model for credential tokens issued by providers.""" - provider: str # e.g. "google", "github", etc. - token: TokenSchema # The token data - - def __init__(self, provider: str, **token: Unpack[TokenSchema]): - self.provider = provider - self.token = token - - def __repr__(self) -> str: - """String representation of the CredentialToken.""" - return f"CredentialToken(provider={self.provider}, token={self.token})" - - @property - def token_type(self) -> str: - return self.token["token_type"] - - @property - def access_token(self) -> str: - return self.token["access_token"] - - @property - def expires_at(self) -> schema.DateTime: - return self.token["expires_at"] - - @property - def scopes(self) -> list[str]: - return self.token["scopes"] - - @property - def refresh_token(self) -> str | None: - return self.token.get("refresh_token") - - @property - def refresh_token_expires_at(self) -> schema.DateTime | None: - return self.token.get("refresh_token_expires_at") - - def to_dict(self) -> TokenSchema: - """Convert the token to a dictionary representation.""" - return self.token - - def is_expired(self, at_time: utc_time.datetime | None = None) -> bool: - """Check if the token is expired based on the current time.""" - - return utc_time.is_expired( - self.expires_at.to_datetime(), - at_time=at_time or utc_time.now(), - threshold=EXPIRY_THRESHOLD - ) - - def refresh_from_response(self, response: TokenResponseSchema) -> None: - """Update the token from a token response.""" - token = self.prepare_token_from_response(response) - self.token = token - - @classmethod - def from_dict(cls, provider: str, token: TokenSchema) -> "CredentialToken": - """Create a CredentialToken instance from a dictionary.""" - return cls(provider, **token) - - @classmethod - def from_response(cls, provider: str, response: TokenResponseSchema) -> "CredentialToken": - """Create a CredentialToken instance from a token response.""" - token = cls.prepare_token_from_response(response) - return cls(provider, **token) - - @staticmethod - def prepare_token_from_response(response: TokenResponseSchema) -> TokenSchema: - """Prepare a token dictionary from a token response.""" - token: TokenSchema = { - "token_type": response["token_type"], - "access_token": response["access_token"], - "expires_at": schema.DateTime.utcafter( - seconds=response["expires_in"] - ), - "scopes": response["scope"].split(" "), - } - if "refresh_token" in response: - token["refresh_token"] = response["refresh_token"] - if "refresh_token_expires_in" in response: - token["refresh_token_expires_at"] = schema.DateTime.utcafter( - seconds=response["refresh_token_expires_in"] - ) - return token diff --git a/campus/models/__init__.py b/campus/models/__init__.py deleted file mode 100644 index a9199f5b..00000000 --- a/campus/models/__init__.py +++ /dev/null @@ -1,125 +0,0 @@ -"""campus.models - -# Campus API Model - -This module contains models for the Campus API server. - -Each model encapsulates the logic for representing and manipulating Campus -resources. - -## Principles - -### Storage-agnostic - -- Models should be storage-agnostic and should not depend on the underlying - storage implementation. -- This means the schema for models should aim to use standard/common features - of databases in general, and avoid database-specific features. - -### Transparency - -- The design of models should be intutitive and easy to understand. -- Some denormalisation is acceptable, to avoid excessive abstraction that - obscures unnecessarily the underlying data model. - -### Extensibility - -- Where possible, the design of models should leave room for extensibility. -- This means that the design should not be too rigid, and should allow for - future changes without requiring significant refactoring. -- It is worth leaving some performance on the table to keep the data model - flexible. - -### Avoid tight coupling - -- Avoid tight coupling with frameworks. -- where it is easy to do so, use callbacks and hooks that allow behaviour to be - specified or customised by users, unless this overly obscures the model logic. -- As much as possible, promote the use of decorators and other Pythonic patterns - to allow users to extend the model behaviour. - -## Conventions - -### Consistent ID pattern - -- For ease of lookup generalisation, all models must use a consistent ID - pattern. -- The design of the ID pattern should not depend on features of a specific - database or database type. - -### Mirror Campus API operations - -- The naming conventions for model methods should mirror the Campus API - operations and verbs. -- Model users should be able to intuit the method arguments, rquiring - documentation only to confirm usage or understand nuances more clearly. - -### Support native Python types - -- Campus is not trying to be a macroframework; a user should not need to know - an entire class hierarchy to implement a Campus server. -- Where annotation and encapsulation is desired, try to subclass or extend the - behaviour of existing Python classes and APIs, in a way that does not obscure - the logic of the model. -- However, low-level details should be delegated to util functions, for - easier standardised handling of common tasks, e.g. datetime conversion and - formatting. - -### Typing conventions - -- Request and response bodies should be typed using TypedDict. -- Where TypedDicts are an acceptable argument type, use Mapping[str, Any] or - Mapping[str, JsonSerializable]. -- Explicitly specify total=True or total=False for TypedDicts. -- Use NotRequired for record/resource fields that might not exist, or be None. -- When the project is updated to python>=3.13, use ReadOnly to indicate fields - that should not be modified, e.g. `id`, `created_at`. - -### Naming conventions - -- Request and response body schemas should be suffixed with the verb they - represent, e.g. `UserNew`, `UserUpdate`. -- Model schemas should be suffixed with `Resource`, e.g. `UserResource`. -- Models and schemas should be named in singular, e.g. `UserResource`, since - they represent a single resource record. - This makes it easier to differentiate records and resources. - -### Validation - -- While parameter types are declared in the submodules of `models`, Models are - not responsible for validating the types and formats of their arguments. -- That would involve importing many more modules, violating the Single - Responsibility Principle. -- Model users should validate their arguments before passing them to the model. -- The model will generally rely on the backend to raise errors if input is - invalid. -""" - -__all__ = [ - "circle", - "emailotp", - "user" -] - -from campus.common import devops - -from . import ( - circle, - emailotp, - user, - event -) - - -@devops.block_env(devops.PRODUCTION) -@devops.confirm_action_in_env(devops.STAGING) -def init_db(): - """Initialize tables needed by models. - - This function is intended to be called only in a test environment (using a - local-only db like SQLite), or in a staging environment before upgrading to - production. - """ - circle.init_db() - emailotp.init_db() - user.init_db() diff --git a/campus/models/base.py b/campus/models/base.py deleted file mode 100644 index ceebee24..00000000 --- a/campus/models/base.py +++ /dev/null @@ -1,46 +0,0 @@ -"""campus.models.base - -Base types and classes for all Campus models. -""" - -from dataclasses import asdict, dataclass, field -from typing import Any, Self, Type, TypedDict - -from campus.common import schema - - -class BaseRecordDict(TypedDict): - """Base class for all records in the Campus system. - - Records are Mapping objects that represent a single record in the database. - BaseRecord reflects the keys that are common to all records in the system. - """ - id: schema.CampusID | schema.UserID - created_at: schema.DateTime - - -# Issue 201: refactoring to dataclasses -# See https://github.com/nyjc-computing/campus/issues/201 -@dataclass(eq=False, kw_only=True) -class BaseRecord: - """Base class for all record models in Campus. - - Subclasses are expected to provide their own CampusID factories. - """ - id: schema.CampusID = field(init=True) - created_at: schema.DateTime = field(default_factory=schema.DateTime.utcnow) - - @classmethod - def from_dict(cls: Type[Self], data: dict) -> Self: - """Create a record from a dictionary.""" - return cls(**data) - - def to_dict(self) -> dict[str, Any]: - """Convert the record to a dictionary.""" - return asdict(self) - - -@dataclass(eq=False, kw_only=True) -class UserRecord(BaseRecord): - """Base class for user records in Campus.""" - id: schema.UserID = field(init=True) diff --git a/campus/models/circle.py b/campus/models/circle.py deleted file mode 100644 index f96c9681..00000000 --- a/campus/models/circle.py +++ /dev/null @@ -1,498 +0,0 @@ -"""campus.models.circle - -This module provides classes for managing Campus circles. - -Data structures: -- collections (Circle) -- address tree - -Main operations: -- CRUD -- resolve source access for a particular user -- get flat list of users (leaf circles) of any circle -- move circles -""" - -from collections.abc import Iterator, Mapping -from dataclasses import dataclass, field -from typing import Any, NotRequired, TypedDict, Unpack - -from campus.common import schema -from campus.common.errors import api_errors -from campus.common.utils import uid -from campus.common import devops -from campus.models.base import BaseRecord, BaseRecordDict -from campus.storage import ( - errors as storage_errors, - get_collection -) - -# TODO: Replace with OpenAPI-based string-pattern schema -AccessValue = int -CircleID = schema.CampusID -CirclePath = str -CircleTag = str -CircleTree = dict[CircleID, "CircleTree"] - -# TODO: Make domain configurable -DOMAIN = "nyjc.edu.sg" -COLLECTION = "circles" - - -# TODO: Refactor settings into a separate model -@devops.block_env(devops.PRODUCTION) -def init_db(): - """Initialize the collections needed by the model. - - This function is intended to be called only in a test environment or - staging. - For MongoDB, collections are created automatically on first insert. - """ - # Initialize the collection (creates it if needed) - storage = get_collection(COLLECTION) - storage.init_collection() - - # Ensure meta record exists - try: - meta_record = get_circle_meta() - except api_errors.NotFoundError: - # Circle meta record not found in collection - storage.insert_one({ - # The meta document id is unused but required by the - # storage interface - schema.CAMPUS_KEY: uid.generate_category_uid("meta", length=8), - "created_at": schema.DateTime.utcnow(), - "@meta": True - }) - meta_record = get_circle_meta() - - # Check for existing root circle, otherwise create one - root_circles = storage.get_matching({"name": DOMAIN}) - assert len(root_circles) <= 1, ( - root_circles, "More than one root circle found" - ) - if not root_circles: - root_circle = Circle().new( - name=DOMAIN, - description="Root circle", - tag="root", - parents={} - ) - else: - root_circle = CircleRecord.from_dict(root_circles[0]) - if "root" not in meta_record or not meta_record["root"]: - update_circle_meta( - { - "root": root_circle.id, - root_circle.id: {} - } - ) - # Check for existing admin circle, otherwise create one - admin_circles = storage.get_matching({"name": "campus-admin"}) - assert len(admin_circles) <= 1, ( - admin_circles, "More than one admin circle found" - ) - if not admin_circles: - admin_circle = Circle().new( - name="campus-admin", - description="Campus admin circle", - tag="admin", - parents={root_circle.id: 15} - ) - else: - admin_circle = admin_circles[0] - - -class CircleNew(TypedDict, total=True): - """Request body schema for a circles.new operation. - - Circles must be created with at least one parent (default: admin). - The `parents` property maps the circle's full path - ({parent path} / {circle_id}) to its access value in that parent. - """ - name: str - description: NotRequired[str] - tag: CircleTag - parents: NotRequired[dict[CirclePath, AccessValue]] - - -class CircleUpdate(TypedDict, total=False): - """Request body schema for a circles.update operation.""" - name: str - description: str - # tag cannot be updated once created - - -class CircleRecordDict(BaseRecordDict): - """The circle record stored in the circle collection.""" - name: str - description: NotRequired[str] - tag: CircleTag - members: dict[CircleID, AccessValue] - - -class CircleResource(CircleRecordDict, total=False): - """Response body schema representing the result of a circles.get operation.""" - # TODO: store ancestry tree - # ancestry: CircleTree - sources: dict # SourceID, SourceHeader - - -@dataclass(eq=False, kw_only=True) -class CircleRecord(BaseRecord): - """Dataclass representation of a circle record.""" - name: str - description: str = "" - tag: CircleTag - members: dict[CircleID, AccessValue] = field(default_factory=dict) - sources: dict = field(default_factory=dict) - - -class CircleMemberRemove(TypedDict): - """Request body schema for a circles.members.remove operation""" - member_id: CircleID - - -class CircleMemberAdd(CircleMemberRemove): - """Request body schema for a circles.members.add operation""" - access_value: AccessValue - - -class CircleMemberSet(CircleMemberRemove): - """Request body schema for a circles.members.set operation""" - access_value: AccessValue - - -# Meta record classes and helper functions - -class CircleMeta(TypedDict, total=False): - """Circle meta schema for the circles collection. - - A meta record is used to store metadata about the circle collection. - This record must be present before any circle operations are attempted. - This is used to store the root circle and the address tree. - """ - root: CircleID - # Some keys are required but (intentionally) are unrepresentable - # in TypedDict - # These are added here for documentation purposes - # @meta: bool # always True - # : CircleTree # circle address tree - - -def get_circle_meta() -> dict: - """Get the circle meta record from the settings collection.""" - storage = get_collection(COLLECTION) - try: - circle_metas = storage.get_matching({"@meta": True}) - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - if not circle_metas: - raise api_errors.NotFoundError( - message=f"Circle meta record not found in collection {COLLECTION}", - id=DOMAIN - ) - assert len(circle_metas) == 1, ( - circle_metas, "Expected exactly one circle meta record" - ) - return circle_metas[0] - - -def update_circle_meta(update: dict) -> None: - """Update the circle meta record in the settings collection.""" - storage = get_collection(COLLECTION) - try: - storage.update_matching( - {"@meta": True}, - update - ) - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - - -def get_root_circle() -> "CircleRecordDict": - """Get the root circle.""" - circle_meta = get_circle_meta() - if "root" not in circle_meta: - raise api_errors.InternalError( - message=f"'root' not set in collection {COLLECTION}", - id=DOMAIN - ) - return Circle().get(circle_meta["root"]) - - -def get_tree_root() -> "CircleTree": - """Get the root of the Circle tree""" - circle_meta = get_circle_meta() - if "root" not in circle_meta: - raise api_errors.InternalError( - message=f"'root' not set in collection {COLLECTION}", - id=DOMAIN - ) - tree_root = circle_meta[circle_meta["root"]] - return tree_root - - -def get_address_tree() -> "CircleAddressTree": - """Get the address tree of circles.""" - root = get_tree_root() - return CircleAddressTree(root=root) - - -class CircleMember: - """Circle model for handling database operations related to circle members - (subcircles). - """ - - def __init__(self): - """Initialize the Circle model with a storage interface.""" - self.storage = get_collection(COLLECTION) - - def list(self, circle_id: CircleID) -> dict: - """List all members of a circle.""" - try: - record = self.storage.get_by_id(circle_id) - if record is None: - raise api_errors.ConflictError( - message="Circle not found", - id=circle_id - ) - return record.get("members", {}) - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="Circle not found", - id=circle_id - ) from None - except storage_errors.StorageError as e: - raise api_errors.InternalError.from_exception(e) from e - - def add(self, circle_id: CircleID, **fields: Unpack[CircleMemberAdd]) -> None: - """Add a member to a circle.""" - member_id = fields["member_id"] - access_value = fields["access_value"] - # Check if member circle exists - try: - member_circle = self.storage.get_by_id(member_id) - if member_circle is None: - raise api_errors.ConflictError( - message="Member circle not found", - id=member_id - ) - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="Member circle not found", - id=member_id - ) from None - except storage_errors.StorageError as e: - raise api_errors.InternalError.from_exception(e) from e - - # Use direct MongoDB access for nested field updates - storage = get_collection(COLLECTION) - try: - storage.update_by_id( - circle_id, - {f"members.{member_id}": access_value}, - ) - except storage_errors.NoChangesAppliedError as e: - raise api_errors.ConflictError( - message="No changes applied when adding member", - id=circle_id - ) from None - except storage_errors.StorageError as e: - raise api_errors.InternalError.from_exception(e) from e - - def remove(self, circle_id: CircleID, **fields: Unpack[CircleMemberRemove]) -> None: - """Remove a member from a circle.""" - member_id = fields["member_id"] - # Check if member circle is a member of circle - try: - circle = self.storage.get_by_id(circle_id) - if circle is None: - raise api_errors.ConflictError( - message="Circle not found", - id=circle_id - ) - if member_id not in circle.get("members", {}): - raise api_errors.ConflictError( - message="Member not found in circle", - id=member_id - ) - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="Circle not found", - id=circle_id - ) from None - except storage_errors.StorageError as e: - raise api_errors.InternalError.from_exception(e) from e - - # Use direct MongoDB access for nested field updates - storage = get_collection(COLLECTION) - try: - storage.update_by_id( - circle_id, - {f"members.{member_id}": None}, - ) - except storage_errors.NoChangesAppliedError as e: - raise api_errors.ConflictError( - message="No changes applied when removing member", - id=circle_id - ) from None - except storage_errors.StorageError as e: - raise api_errors.InternalError.from_exception(e) from e - - def set(self, circle_id: CircleID, **fields: Unpack[CircleMemberSet]) -> None: - """Set the access of a member of a circle. - - No validation of existing access is carried out. - """ - # For now, set and add operations are identical - self.add(circle_id, **fields) - - -class Circle: - """Circle model for handling database operations related to circles.""" - members = CircleMember() - - def __init__(self): - """Initialize the Circle model with a storage interface.""" - self.storage = get_collection(COLLECTION) - - def list(self, **filters: Any) -> list[CircleRecord]: - """List all circles in the circle collection. - - Keyword arguments are used to filter the results. - - Args: - **filters: Keyword arguments to filter the circles. - """ - try: - records = self.storage.get_matching(filters) - except storage_errors.StorageError as e: - raise api_errors.InternalError.from_exception(e) from e - else: - return [CircleRecord(**record) for record in records] - - def new(self, **fields: Unpack[CircleNew]) -> CircleRecord: - """This creates a new circle and adds it to the circle collection. - - It does not add it to the circle hierarchy or access control. - """ - # TODO: Add admin as default parent if not specified - parents = fields.pop("parents", {}) - if fields["tag"] == "root" and len(parents) > 0: - raise api_errors.ConflictError( - message="Root circle cannot have parents", - id=fields["tag"] - ) - circle_id = schema.CampusID( - uid.generate_category_uid("circle", length=8) - ) - record = CircleRecord( - id=circle_id, - created_at=schema.DateTime.utcnow(), - name=fields["name"], - description=fields.get("description", ""), - tag=fields["tag"], - members={}, - ) - # TODO: Store ancestry tree - # TODO: Use transactions for atomic creation of circles and their parents - # https://www.mongodb.com/docs/languages/python/pymongo-driver/upcoming/write/transactions/ - try: - self.storage.insert_one(record.to_dict()) - for parent_id, access_value in parents.items(): - # TODO: Drum notation for updating nested fields - self.members.add( - parent_id, - member_id=circle_id, - access_value=access_value - ) - except storage_errors.StorageError as e: - raise api_errors.InternalError.from_exception(e) from e - else: - # Return as CircleResource (add sources field) - # TODO: join with sources and access values - record.sources = {} - return record - - - def delete(self, circle_id: str) -> None: - """Delete a circle by id. - - This action is destructive and cannot be undone. - It should only be done by an admin/owner. - """ - # TODO: Check circle ancestry, remove from parents' members - try: - self.storage.delete_by_id(circle_id) - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="Circle not found", - id=circle_id - ) from None - except storage_errors.StorageError as e: - raise api_errors.InternalError.from_exception(e) from e - - def get(self, circle_id: str) -> CircleResource: - """Get a circle by id from the circle collection.""" - try: - record = self.storage.get_by_id(circle_id) - if record is None: - raise api_errors.ConflictError( - message="Circle not found", - id=circle_id - ) - # TODO: join with sources and access values - resource = CircleResource(**record) - # TODO: join with sources and access values - resource["sources"] = {} - return resource - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="Circle not found", - id=circle_id - ) from None - except storage_errors.StorageError as e: - raise api_errors.InternalError.from_exception(e) from e - - def update(self, circle_id: str, **updates: Unpack[CircleUpdate]) -> None: - """Update a circle by id.""" - try: - self.storage.update_by_id(circle_id, dict(updates)) - except storage_errors.NoChangesAppliedError as e: - return None # No changes applied, nothing to do - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="Circle not found", - id=circle_id - ) from None - except storage_errors.StorageError as e: - raise api_errors.InternalError.from_exception(e) from e - - -class CircleAddressTree(Mapping[CircleID, "CircleAddressTree"]): - """Circle address tree for managing circle hierarchy. - - While each circle already stores its descendancy information, - this class provides a more efficient way to trace members of any circle. - - The address tree does not include user circles, for reasons of size and - speed (MongoDB limits documents to 16MB). - """ - - def __init__(self, root: CircleTree): - self.root = root - - def __getitem__(self, key: CircleID) -> "CircleAddressTree": - """Get a circle tree by its ID.""" - if key not in self.root: - raise KeyError(f"Circle ID {key} not found in address tree.") - return CircleAddressTree(self.root[key]) - - def __iter__(self) -> Iterator[CircleID]: - """Iterate over the circle IDs in the address tree.""" - return iter(self.root) - - def __len__(self) -> int: - """Get the number of circles in the address tree.""" - return len(self.root) diff --git a/campus/models/credentials.py b/campus/models/credentials.py deleted file mode 100644 index 4e4af5cb..00000000 --- a/campus/models/credentials.py +++ /dev/null @@ -1,214 +0,0 @@ -"""campus.models.credentials - -Credentials model for the Campus API. - -Credentials are long-lived secrets, typically used for authorisation. - -Credentials are assumed to be issued by a provider. -""" - -from typing import NotRequired, TypedDict, Unpack - -from campus.common import schema -from campus.common.errors import api_errors -from campus.common.webauth.token import TokenSchema -from campus.storage import ( - errors as storage_errors, - get_collection -) - -COLLECTION = "credentials" - - -class ClientCredentialsSchema(TypedDict): - """TokenCredentials type for storing access and refresh tokens.""" - id: NotRequired[str] # Primary key, only used internally - provider: NotRequired[str] # added by ClientCredentials - client_id: schema.CampusID # must be provided - issued_at: schema.DateTime - token: TokenSchema - - -class UserCredentialsSchema(TypedDict): - """TokenCredentials type for storing access and refresh tokens.""" - id: NotRequired[str] # Primary key, only used internally - provider: NotRequired[str] # added by UserCredentials - user_id: schema.CampusID # must be provided - issued_at: schema.DateTime - token: TokenSchema - - -class ClientCredentials: - """Model for client credentials. - - Client credentials are issued for clients, typically in the form of an - access token and optionally a refresh token. They are identified by a - client ID. - - Scopes may be included in the credentials, but are not required. - - The client credentials are assumed to be issued by Campus. - """ - - def __init__(self, provider: str = "campus"): - self.provider = provider - self.storage = get_collection(COLLECTION) - - def delete(self, client_id: schema.CampusID) -> None: - """Delete a client credential by its ID.""" - try: - self.storage.delete_by_id(client_id) - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="Client credential not found", - client_id=client_id - ) from e - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - - def get(self, client_id: schema.CampusID) -> dict | None: - """Retrieve a client credential by its ID.""" - try: - record = self.storage.get_by_id(client_id) - except storage_errors.NotFoundError as e: - api_errors.raise_api_error( - 404, message="Client credential not found") - except Exception as e: - if isinstance(e, type(api_errors.APIError)) and hasattr(e, 'status_code'): - raise # Re-raise API errors as-is - raise api_errors.InternalError(message=str(e), error=e) - if record is None: - api_errors.raise_api_error( - 404, message="Client credential not found") - return record - - def store(self, credentials: dict) -> None: - """Store a client credential with the given ID and data.""" - try: - # Add id primary key which is needed by the backend interface. - assert schema.CAMPUS_KEY in credentials, "Client credentials must have an ID" - credentials_data = dict(credentials) - - # Check if record already exists - try: - existing_record = self.storage.get_by_id( - credentials_data[schema.CAMPUS_KEY]) - except storage_errors.NotFoundError: - existing_record = None - # Other exceptions are handled below - if existing_record is not None: - # If the record already exists, we update it. - try: - self.storage.update_by_id( - credentials_data[schema.CAMPUS_KEY], - credentials_data - ) - except storage_errors.NoChangesAppliedError as e: - raise api_errors.ConflictError( - message="No client credential updated", - client_id=credentials_data[schema.CAMPUS_KEY] - ) from e - # Other exceptions are handled below - else: - try: - self.storage.insert_one(credentials_data) - except storage_errors.ConflictError as e: - raise api_errors.ConflictError( - message="Client credential conflict", - client_id=credentials_data[schema.CAMPUS_KEY] - ) from e - # Other exceptions are handled below - except Exception as e: - if isinstance(e, AssertionError): - raise # Re-raise assertion errors as-is - raise api_errors.InternalError.from_exception(e) from e - - -class UserCredentials: - """Model for user credentials. - - User credentials are issued for users, typically in the form of an - access token and optionally a refresh token. They are identified by a - provider and user ID. - - Scopes may be included in the credentials, but are not required. - """ - provider: str - - def __init__(self, provider: str): - self.provider = provider - self.storage = get_collection(COLLECTION) - - def delete(self, user_id: schema.CampusID) -> None: - """Delete user credentials by ID.""" - try: - self.storage.delete_matching( - {"provider": self.provider, "user_id": user_id} - ) - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="User credentials not found", - user_id=user_id - ) from e - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - - def get(self, user_id: schema.CampusID) -> UserCredentialsSchema: - """Retrieve user credentials by user ID.""" - try: - records = self.storage.get_matching( - {"provider": self.provider, "user_id": user_id} - ) - except storage_errors.NotFoundError as e: - raise api_errors.NotFoundError( - message="User credentials not found", - user_id=user_id - ) from e - except Exception as e: - raise api_errors.InternalError(message=str(e), error=e) - else: - record = records[0] - # Remove the primary key field from the record - # Make a copy to avoid modifying the original - credentials_data = dict(record) - if schema.CAMPUS_KEY in credentials_data: - del credentials_data[schema.CAMPUS_KEY] - return credentials_data # type: ignore - - def store(self, **credentials: Unpack[UserCredentialsSchema]) -> None: - """Store user credentials with the given data.""" - assert credentials.get("provider", self.provider) == self.provider, \ - "Provider mismatch in credentials" - # Add id primary key which is needed by the backend interface. - token_id = self.provider + ":" + credentials["user_id"] - credentials_data = dict(credentials) - credentials_data[schema.CAMPUS_KEY] = token_id - credentials_data["provider"] = self.provider - try: - # Check if record already exists - try: - existing_record = self.storage.get_by_id(token_id) - except storage_errors.NotFoundError: - existing_record = None - # Other exceptions are handled below - if existing_record is not None: - # If the record already exists, update it. - try: - self.storage.update_by_id(token_id, credentials_data) - except storage_errors.NoChangesAppliedError as e: - raise api_errors.ConflictError( - message="No user credentials updated", - user_id=token_id - ) from e - # Other exceptions are handled below - else: - try: - self.storage.insert_one(credentials_data) - except storage_errors.ConflictError as e: - raise api_errors.ConflictError( - message="User credentials conflict", - user_id=token_id - ) from e - # Other exceptions are handled below - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e diff --git a/campus/models/emailotp/__init__.py b/campus/models/emailotp/__init__.py deleted file mode 100644 index 4ff2f931..00000000 --- a/campus/models/emailotp/__init__.py +++ /dev/null @@ -1,248 +0,0 @@ -"""campus.models.emailotp - -This module provides classes and utilities for handling one-time -passwords (OTPs) used in email authentication. It includes functionality -generating, hashing, verifying, and managing OTPs securely. -""" -# TODO: Move to common.services - -import secrets -from typing import TypedDict, Unpack - -import bcrypt - -from campus.common import devops, schema -from campus.common.errors import api_errors -from campus.common.utils import uid, utc_time -from campus.models.base import BaseRecordDict -from campus.storage import ( - errors as storage_errors, - get_table, -) - -TABLE = "emailotp" - - -@devops.block_env(devops.PRODUCTION) -def init_db(): - """Initialize the tables needed by the model. - - This function is intended to be called only in a test environment (using a - local-only db like SQLite), or in a staging environment before upgrading to - production. - """ - storage = get_table(TABLE) - schema = f""" - CREATE TABLE IF NOT EXISTS "{TABLE}" ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL, - otp_hash TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL - ) - """ - storage.init_table(schema) - - -class _plainOTP(str): - """ - Represents a plaintext OTP for authentication. - - Provides methods to generate a secure OTP and hash it for secure storage. - """ - - @classmethod - def generate(cls, length: int = 6) -> "_plainOTP": - """ - Generate a secure random OTP of specified length (default: 6 digits). - - Args: - length: Length of the OTP (default: 6). - - Returns: - A string containing the generated OTP. - """ - passcode: int = secrets.randbelow(10 ** length) - return _plainOTP(f"{passcode:0{length}d}") - - def hash(self) -> "_hashedOTP": - """ - Hash the OTP using bcrypt for secure storage. - - Returns: - A hashedOTP instance containing the hashed OTP. - """ - otp_bytes = self.encode('utf-8') - salt = bcrypt.gensalt() - hashed = bcrypt.hashpw(otp_bytes, salt) - return _hashedOTP(hashed.decode('utf-8')) - - -class _hashedOTP(str): - """ - Represents a hashed OTP for verification. - - Provides a method to verify if a plaintext OTP matches the hashed OTP. - """ - - def verify(self, plain_otp: "_plainOTP") -> bool: - """ - Verify if a plaintext OTP matches this hashed OTP. - - Args: - plain_otp: The plaintext OTP to verify. - - Returns: - True if the plaintext OTP matches the hashed OTP, False otherwise. - """ - plain_bytes = plain_otp.encode('utf-8') - hashed_bytes = self.encode('utf-8') - return bcrypt.checkpw(plain_bytes, hashed_bytes) - - -class OTPRequest(TypedDict, total=True): - """Request body schema for an emailotp.new operation.""" - email: str - - -class OTPVerify(OTPRequest, total=True): - """Request body schema for an emailotp.verify operation.""" - otp: str - - -class OTPRecord(OTPRequest, BaseRecordDict, total=True): - """Schema for a complete OTP record. - Currently unused in the API, provided for documentation purpose. - """ - otp_hash: str - expires_at: schema.DateTime - - -class EmailOTPAuth: - """ - OTP model for handling database operations related to one-time passwords. - - Provides methods to create, verify, and delete OTPs associated with email addresses. - """ - - def __init__(self): - """Initialize the OTP model with a storage interface. - - Args: - storage: Implementation of StorageInterface for database operations. - """ - self.storage = get_table(TABLE) - - def request(self, email: str, expiry_minutes: int | float = 5) -> str: - """Generate a new OTP for the given email, store or update it in the database, - and return it. - - Args: - email: Email address to associate with the OTP. - expiry_minutes: Expiration time in minutes (default: 5). - - Returns: - The plaintext OTP (to be sent to the user). - """ - # Generate a new OTP - plain_otp = _plainOTP.generate() - # Hash the OTP for secure storage - otp_hash = plain_otp.hash() - # Set expiration and creation times - now = schema.DateTime.utcnow() - created_at = now - expires_at = schema.DateTime.utcafter(now, minutes=expiry_minutes) - - try: - # Delete any existing OTP for this email (find by email field) - try: - existing_otps = self.storage.get_matching({"email": email}) - except storage_errors.NotFoundError: - existing_otps = [] - for otp_record in existing_otps: - try: - self.storage.delete_by_id(otp_record[schema.CAMPUS_KEY]) - except storage_errors.NotFoundError: - continue - - # Insert new OTP - otp_id = uid.generate_category_uid(TABLE, length=16) - otp_code = OTPRecord( - id=otp_id, - email=email, - otp_hash=otp_hash, - created_at=created_at, - expires_at=expires_at, - ) - try: - self.storage.insert_one(dict(otp_code)) - except storage_errors.ConflictError as e: - raise api_errors.ConflictError( - message="OTP conflict during insert", - email=email - ) from e - else: - return plain_otp - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - - def verify(self, **data: Unpack[OTPVerify]) -> None: - """Verify if the provided OTP matches the one stored for the email. - - Args: - email: Email address to check. - plain_otp: Plaintext OTP to verify. - - Returns: - ModelResponse indicating the result of the verification. - """ - try: - # Get the latest OTP for this email - try: - otp_records = self.storage.get_matching( - {"email": data['email']}) - except storage_errors.NotFoundError: - raise api_errors.ConflictError("OTP not found") - # Get the most recent OTP record (assuming they're ordered by creation time) - record = otp_records[0] - hashed_otp = _hashedOTP(record['otp_hash']) - expires_at = schema.DateTime(record['expires_at']) - - # Check if OTP is expired - if utc_time.is_expired(expires_at.to_datetime()): - raise api_errors.UnauthorizedError("OTP expired") - - # Verify OTP - if hashed_otp.verify(_plainOTP(data['otp'])): - return - else: - raise api_errors.UnauthorizedError("Invalid OTP") - except Exception as e: - if isinstance(e, type(api_errors.APIError)) and hasattr(e, 'status_code'): - raise # Re-raise API errors as-is - raise api_errors.InternalError.from_exception(e) from e - - def revoke(self, email: str) -> None: - """Delete all OTPs for the given email (typically after successful - verification). - - Args: - email: Email address to delete OTPs for. - """ - try: - # Find all OTPs for this email - try: - otp_records = self.storage.get_matching({"email": email}) - except storage_errors.NotFoundError: - raise api_errors.ConflictError("OTP not found") - - # Delete all OTP records for this email - for record in otp_records: - try: - self.storage.delete_by_id(record[schema.CAMPUS_KEY]) - except storage_errors.NotFoundError: - continue - except Exception as e: - if isinstance(e, type(api_errors.APIError)) and hasattr(e, 'status_code'): - raise # Re-raise API errors as-is - raise api_errors.InternalError.from_exception(e) from e diff --git a/campus/models/emailotp/template.py b/campus/models/emailotp/template.py deleted file mode 100644 index b0f33632..00000000 --- a/campus/models/emailotp/template.py +++ /dev/null @@ -1,51 +0,0 @@ -"""campus.models.emailotp.template - -Email templates for OTP authentication. -""" - -import os - -from flask import render_template_string - -TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), 'templates') - -# Defer template loading for quick instance startup -plaintext_template = "" -html_template = "" - -def load_plaintext_template() -> None: - """Load the plaintext email template from file.""" - global plaintext_template - with open(os.path.join(TEMPLATE_DIR, "email.txt")) as f: - plaintext_template = f.read().strip() - -def load_html_template() -> None: - """Load the HTML email template from file.""" - global html_template - with open(os.path.join(TEMPLATE_DIR, "email.html")) as f: - html_template = f.read().strip() - - -def subject(service: str, otp: str) -> str: - """Email subject for OTP authentication.""" - return f"{service.title()}: Your OTP is {otp}" - -def body(service: str, otp: str) -> str: - """Plaintext email body for OTP authentication.""" - if not plaintext_template: - load_plaintext_template() - return render_template_string( - plaintext_template, - service=service, - otp=otp - ) - -def html_body(service: str, otp: str) -> str: - """HTML email body for OTP authentication.""" - if not html_template: - load_html_template() - return render_template_string( - html_template, - service=service, - otp=otp - ) diff --git a/campus/models/emailotp/templates/email.html b/campus/models/emailotp/templates/email.html deleted file mode 100644 index 74e50f48..00000000 --- a/campus/models/emailotp/templates/email.html +++ /dev/null @@ -1,3 +0,0 @@ -

Your OTP is: {{otp}}

-

This OTP will expire in 2 minutes.

-

This is an autogenerated email from {{service}}. Please do not reply to this email.

\ No newline at end of file diff --git a/campus/models/emailotp/templates/email.txt b/campus/models/emailotp/templates/email.txt deleted file mode 100644 index 008eaddb..00000000 --- a/campus/models/emailotp/templates/email.txt +++ /dev/null @@ -1,5 +0,0 @@ -Your One-Time Password (OTP) for {{ service }} is: {{ otp }} - -This OTP will expire in 2 minutes. - -Note: This email is auto-generated. Please do not reply to this email. \ No newline at end of file diff --git a/campus/models/session/__init__.py b/campus/models/session/__init__.py deleted file mode 100644 index 59ea3e9c..00000000 --- a/campus/models/session/__init__.py +++ /dev/null @@ -1,244 +0,0 @@ -"""campus.models.session - -Login Session model for the Campus API. - -Sessions are long-lived records of authentication associated with a specific: -- application (client_id) -- user (user_id) -- device (agent_string) -(client_id, user_id, agent_string) constitute a unique key for a session. -The session_id is stored client-side in a cookie, and used as a primary -identifier. -Sessions must have an expiry datetime, for pruning. -Sessions are only initiated by client, but may be revoked by either party. - -Additional metadata may be included, e.g. for use for OAuth - -For authenticated HTTP requests in the Campus API, clients must include -the session_id (in cookie), client_id (in header), and access_token (in header). -user_id is retrieved from the session record. -The access token itself is not stored in sessions; it's validated per-request. -""" - -from typing import NotRequired, Required, TypedDict, cast - -from flask import session as client_session - -from campus.common import schema -from campus.common.errors import api_errors -from campus.common.utils import uid -import campus.common.validation.record as record_validation -from campus.models.base import BaseRecordDict -from campus.storage import ( - errors as storage_errors, - get_collection -) - -COLLECTION = "sessions" -SESSION_KEY = "session_id" - - -class SessionRecord(BaseRecordDict): - """Schema for a full session record.""" - expires_at: str - client_id: schema.CampusID - user_id: schema.UserID - agent_string: str - # fields for OAuth sessions - scopes: NotRequired[list[str]] - authorization_code: NotRequired[str] - target: NotRequired[str] - redirect_uri: NotRequired[str] - - -class SessionNew(TypedDict, total=False): - """Schema for a new session request.""" - client_id: Required[schema.CampusID] - user_id: Required[schema.UserID] - agent_string: str - # A session may include specific scopes - scopes: list[str] - - -class Sessions: - """Model for Sessions. - - This model represents a Session in the database. - Sessions are manipulated by session_id. Where session_id is not provided, - the session_id stored in the client cookie will be used. - """ - - def __init__(self): - """Initialize the Session model with a collection storage interface.""" - self.storage = get_collection(COLLECTION) - - def _verify_session_id( - self, - session_id: schema.CampusID | None = None - ) -> schema.CampusID: - """Get the session ID from the client cookie, if it exists. - If session_id is provided, it is verified against the client cookie and - returned if they match. - Returns None if no session ID is found or if there is a mismatch. - This avoids accidental deletion of a session that does not belong to the - client. - """ - client_session_id = client_session.get(SESSION_KEY) - if session_id and session_id != client_session_id: - raise api_errors.ConflictError( - message="Mismatch with client session ID", - session_id=session_id - ) from None - if not client_session_id: - raise api_errors.NotFoundError( - message="No session ID in client", - session_id=session_id - ) from None - return client_session_id - - def delete(self, session_id: schema.CampusID | None = None) -> None: - """Delete a session by its ID.""" - # Check if session exists client-side - session_id = self._verify_session_id(session_id) - # Remove server-side session - try: - self.storage.delete_by_id(session_id) - except storage_errors.NotFoundError: - raise api_errors.ConflictError( - message="Session not found", - session_id=session_id - ) - except Exception as e: - raise api_errors.InternalError(message=str(e), error=e) - else: - # For consistency, only remove client-side session after - # successful server-side deletion - del client_session[SESSION_KEY] - - def find( - self, - *, - client_id: schema.CampusID | None = None, - user_id: schema.UserID | None = None, - agent_string: str | None = None, - # expire_after: schema.DateTime | None = None, - # expire_before: schema.DateTime | None = None, - limit: int = 100, - ) -> list[SessionRecord]: - """Find sessions matching the given criteria. - At least one filter must be provided. - """ - query = {} - if client_id: - query["client_id"] = client_id - if user_id: - query["user_id"] = user_id - if agent_string: - query["agent_string"] = agent_string - # TODO: DSL for query ranges - # if expire_after or expire_before: - # query["expires_at"] = {} - # if expire_after: - # query["expires_at"]["$gte"] = expire_after - # if expire_before: - # query["expires_at"]["$lte"] = expire_before - if not query: - raise api_errors.InvalidRequestError( - message="At least one filter must be provided" - ) - query["$limit"] = limit - try: - records = self.storage.get_matching(query) - except Exception as e: - raise api_errors.InternalError(message=str(e), error=e) - else: - return [SessionRecord(**r) for r in records] - - def get( - self, - session_id: schema.CampusID | None = None - ) -> SessionRecord | None: - """Retrieve a session by its ID, or return None if not found. - - If session_id is not provided, uses the session ID from the client - cookie. - """ - session_id = self._verify_session_id(session_id) - return self.get_by_id(session_id) - - def get_by_id(self, session_id: schema.CampusID) -> SessionRecord: - """Retrieve a session by its ID.""" - try: - record = self.storage.get_by_id(session_id) - except storage_errors.NotFoundError: - raise api_errors.NotFoundError( - message="Session not found", - session_id=session_id - ) from None - except Exception as e: - raise api_errors.InternalError(message=str(e), error=e) - else: - return cast(SessionRecord, record) - - def new(self, session_data: dict, *, expiry_seconds: int) -> SessionRecord: - """Create a new session. - - Any existing session will be revoked. - """ - # Delete any existing session - try: - session_id = self._verify_session_id() - except (api_errors.NotFoundError, api_errors.ConflictError): - # No existing session, or mismatch - ignore - pass - else: - self.delete(session_id) - record_validation.validate_keys( - session_data, - valid_keys=SessionNew.__annotations__, - ignore_extra=False, - ) - session_id = uid.generate_category_uid(COLLECTION) - session_data[schema.CAMPUS_KEY] = session_id - now = schema.DateTime.utcnow() - session_data["created_at"] = now - session_data["expires_at"] = schema.DateTime.utcafter( - now, seconds=expiry_seconds - ) - try: - self.storage.insert_one(session_data) - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - else: - client_session[SESSION_KEY] = session_data[schema.CAMPUS_KEY] - return cast(SessionRecord, session_data) - - def update( - self, - session_id: schema.CampusID | None = None, - **update - ) -> SessionRecord: - """Update an existing session.""" - session_id = self._verify_session_id(session_id) - for immutable_key in ( - schema.CAMPUS_KEY, - "client_id", - "user_id", - "agent_string", - "created_at", - ): - if immutable_key in update: - raise api_errors.InvalidRequestError( - message=f"Cannot update immutable field: {immutable_key}" - ) - try: - self.storage.update_by_id(session_id, update) - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="Session not found", - session_id=session_id - ) from e - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - else: - return cast(SessionRecord, self.get_by_id(session_id)) diff --git a/campus/models/source/__init__.py b/campus/models/source/__init__.py deleted file mode 100644 index 5292014a..00000000 --- a/campus/models/source/__init__.py +++ /dev/null @@ -1,151 +0,0 @@ -"""campus.models.source -Source Models - -This module provides classes for creating and managing Campus sources, which -are data sources from third-party platforms and APIs. - -Data structures: -- collections (Integrations) - -Main operations: -- -""" - -__all__ = [] - -from typing import NotRequired, TypedDict, Unpack - -from campus.models.base import BaseRecordDict -from campus.common import devops, schema -from campus.common.errors import api_errors -from campus.common.utils import uid -from campus.storage import get_collection - -SourceID = str - -TABLE = "sources" - - -@devops.block_env(devops.PRODUCTION) -def init_db(): - """Initialize the collections needed by the model. - - This function is intended to be called only in a test environment or - staging. - For MongoDB, collections are created automatically on first insert. - """ - pass - - -class SourceRecord(BaseRecordDict, total=False): - """Schema for a source record in the sources collection.""" - type: str # Source type (integration.type) - external_id: str # Unique ID used by the external platform - name: str # Human-friendly name - description: NotRequired[str] # Optional description - linked_by: str # Circle that linked this source - linked_at: str # ISO timestamp - owner_circles: list[str] # Owning circles — only these can assign access - # Access rules per owning circle - access_policies: dict[str, dict[str, int]] - # Optional live metadata from the external platform - metadata: NotRequired[dict[str, str]] - - -class SourceNew(TypedDict, total=True): - """Request body schema for a sources.new operation.""" - type: str - external_id: str - name: str - description: NotRequired[str] - linked_by: str - linked_at: str - owner_circles: list[str] - access_policies: dict[str, dict[str, int]] - metadata: NotRequired[dict[str, str]] - - -class SourceUpdate(TypedDict, total=False): - """Request body schema for a sources.update operation.""" - name: str - description: str - owner_circles: list[str] - access_policies: dict[str, dict[str, int]] - metadata: dict[str, str] - - -class Source: - """Source model for handling database operations related to sources.""" - - def __init__(self): - """Initialize the Source model with a storage interface.""" - self.storage = get_collection(TABLE) - - def new(self, **fields: Unpack[SourceNew]) -> str: - """This creates a new source.""" - # TODO: add to circle - source_id = SourceID(uid.generate_category_uid("source", length=16)) - record = SourceRecord( - id=source_id, - created_at=schema.DateTime.utcnow(), - name=fields["name"], - description=fields.get("description", ""), - type=fields["type"], - external_id=fields["external_id"], - linked_by=fields["linked_by"], - linked_at=fields["linked_at"], - owner_circles=fields["owner_circles"], - access_policies=fields["access_policies"], - metadata=fields.get("metadata", {}), - ) - try: - self.storage.insert_one(dict(record)) - return source_id - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - - def delete(self, source_id: str) -> None: - """Delete a source by id. - - This action is destructive and cannot be undone. - It should only be done by an admin/owner. - """ - try: - self.storage.delete_by_id(source_id) - except Exception as e: - if isinstance(e, type(api_errors.APIError)) and hasattr(e, 'status_code'): - raise # Re-raise API errors as-is - raise api_errors.InternalError.from_exception(e) from e - - def get(self, source_id: str) -> dict: - """Get a source by id from the source collection.""" - try: - record = self.storage.get_by_id(source_id) - if record is None: - raise api_errors.ConflictError( - message="Source not found", - id=source_id - ) - return record - except Exception as e: - if isinstance(e, type(api_errors.APIError)) and hasattr(e, 'status_code'): - raise # Re-raise API errors as-is - raise api_errors.InternalError.from_exception(e) from e - - def list(self) -> list[dict]: - """List all sources in the sources collection.""" - try: - # Get all documents (excluding metadata documents) - sources = self.storage.get_matching({"@meta": {"$ne": True}}) - return sources - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - - def update(self, source_id: str, **updates: Unpack[SourceUpdate]) -> None: - """Update a source by id.""" - try: - self.storage.update_by_id(source_id, dict(updates)) - except Exception as e: - if isinstance(e, type(api_errors.APIError)) and hasattr(e, 'status_code'): - raise # Re-raise API errors as-is - raise api_errors.InternalError.from_exception(e) from e diff --git a/campus/models/source/sourcetype.py b/campus/models/source/sourcetype.py deleted file mode 100644 index fda3a281..00000000 --- a/campus/models/source/sourcetype.py +++ /dev/null @@ -1,57 +0,0 @@ -"""campus.models.source.sourcetype -SourceType Models - -This module provides classes for creating and managing Campus source types, -which are categories of data sources from third-party platforms and APIs. -""" -from ..integration import ( - CommonCapabilities, - Url, -) - -TABLE = "sourcetypes" - - -class SourceTypeBase: - """Base class for source type config objects.""" - - def __init__( - self, - integration_name: str, - name: str, - description: str, - api_base_url: Url, - resource_id_format: str, - scopes: list[str], - capabilities: CommonCapabilities, - ): - self.integration_name = integration_name - self.name = name - self.description = description - self.api_base_url = api_base_url - self.resource_id_format = resource_id_format - self.scopes = scopes - self.capabilities = capabilities - - @classmethod - def from_dict(cls, data: dict) -> "SourceTypeBase": - return cls( - integration_name=data["integration_name"], - name=data["name"], - description=data["description"], - api_base_url=data["api_base_url"], - resource_id_format=data["resource_id_format"], - scopes=data["scopes"], - capabilities=CommonCapabilities(**data["capabilities"]), - ) - - def to_dict(self) -> dict: - return { - "integration_name": self.integration_name, - "name": self.name, - "description": self.description, - "api_base_url": self.api_base_url, - "resource_id_format": self.resource_id_format, - "scopes": self.scopes, - "capabilities": self.capabilities, - } diff --git a/campus/models/token.py b/campus/models/token.py deleted file mode 100644 index e43f6178..00000000 --- a/campus/models/token.py +++ /dev/null @@ -1,262 +0,0 @@ -"""campus.models.token - -(Bearer) Token model for the Campus API. - -Tokens are issued for: -- a specific Campus client (by client_id) -- a specific Campus user (by user_id) -- specific scopes - -Tokens follow storage interface requirements and will have -`id` and `created_at` fields. - -Tokens are long-lived and may persist over multiple days. -""" - -from dataclasses import dataclass, field -from typing import Any, Literal, TypedDict, overload - -from campus.common import devops, schema -from campus.common.errors import api_errors -from campus.common.utils import secret, utc_time -from campus.models.base import BaseRecord, BaseRecordDict -from campus.storage import ( - errors as storage_errors, - get_table -) - -TABLE = "tokens" -DEFAULT_EXPIRY_SECONDS = utc_time.DAY_SECONDS * 30 # 30 days - - -@devops.block_env(devops.PRODUCTION) -def init_db(): - """Initialize the tables needed by the model. - - This function is intended to be called only in a test environment (using a - local-only db like SQLite), or in a staging environment before upgrading to - production. - """ - storage = get_table(TABLE) - table_schema = f""" - CREATE TABLE IF NOT EXISTS "{TABLE}" ( - id TEXT PRIMARY KEY, - created_at TEXT, - expires_at TEXT, - client_id TEXT, - user_id TEXT, - scopes TEXT, - UNIQUE(client_id, user_id) - ) - """ - storage.init_table(table_schema) - - -class TokenRecordDict(BaseRecordDict): - """Schema for a full token record.""" - expires_at: schema.DateTime - client_id: schema.CampusID - user_id: schema.UserID - scopes: str - - -class TokenNew(TypedDict): - """Schema for a new token request.""" - client_id: schema.CampusID - user_id: schema.UserID - scopes: list[str] - - - - -@dataclass(eq=False, kw_only=True) -class SanitizedTokenRecord: - """Dataclass representation of a sanitized token record. - - This is used to return token information without sensitive fields. - """ - client_id: schema.CampusID - user_id: schema.UserID - scopes: list[str] = field(default_factory=list) - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "SanitizedTokenRecord": - """Create a SanitizedTokenRecord from a dictionary. - - Scopes are stored as space-separated strings in the database, - but as a list in the dataclass. - """ - token_data = dict(data) # Make a copy to avoid mutating input - if isinstance(token_data["scopes"], str): - token_data["scopes"] = token_data["scopes"].split(" ") - return cls(**token_data) - - def get_missing_scopes(self, scopes: str | list[str]) -> list[str]: - """Validate the requested scopes against the session's granted scopes. - Returns the missing scopes. - """ - if isinstance(scopes, str): - scopes = scopes.split(" ") - return [ - scope for scope in scopes - if scope not in self.scopes - ] - - -@dataclass(eq=False, kw_only=True) -class TokenRecord(BaseRecord): - """Dataclass representation of a token record.""" - # access_token is stored in id - id: str = field(default_factory=secret.generate_access_code) - # expires_at is generated in __post_init__ if not provided - expires_at: schema.DateTime = field(default=None) # type: ignore - client_id: schema.CampusID - user_id: schema.UserID - scopes: list[str] = field(default_factory=list) - - def __post_init__(self): - if self.expires_at is None: - self.expires_at = schema.DateTime.utcafter( - self.created_at, seconds=DEFAULT_EXPIRY_SECONDS - ) - - @property - def access_token(self) -> str: - """Convenience property that makes access_token an alias for id.""" - return self.id - - @classmethod - def from_dict(cls, data: dict) -> "TokenRecord": - """Create a TokenRecord from a dictionary. - - Scopes are stored as space-separated strings in the database, - but as a list in the dataclass. - """ - token_data = dict(data) # Make a copy to avoid mutating input - if isinstance(token_data["scopes"], str): - token_data["scopes"] = token_data["scopes"].split(" ") - return super().from_dict(token_data) - - def is_expired(self, *, at_time: schema.DateTime | None = None) -> bool: - """Check if the token is expired at the given time (or now).""" - at_time = at_time or schema.DateTime.utcnow() - return utc_time.is_expired( - self.expires_at.to_datetime(), - at_time=at_time.to_datetime() - ) - - def sanitized(self) -> SanitizedTokenRecord: - """Return a sanitized version of the token record.""" - return SanitizedTokenRecord( - client_id=self.client_id, - user_id=self.user_id, - scopes=self.scopes - ) - - def to_dict(self) -> dict[str, Any]: - """Convert the TokenRecord to a dictionary. - - Scopes are stored as space-separated strings in the database, - but as a list in the dataclass. - """ - data = super().to_dict() - data["scopes"] = " ".join(self.scopes) - return data - - def validate_scope(self, scopes: str | list[str]) -> list[str]: - """Validate the requested scopes against the session's granted scopes. - Returns the missing scopes. - """ - if isinstance(scopes, str): - scopes = scopes.split(" ") - return [ - scope for scope in scopes - if scope not in self.scopes - ] - - -class Tokens: - """Token model for handling database operations related to tokens.""" - - def __init__(self): - """Initialize the Token model with a table storage interface.""" - self.storage = get_table(TABLE) - - def delete(self, token_id: schema.CampusID) -> None: - """Delete a token from the database.""" - self.storage.delete_by_id(token_id) - - @overload - def find(self, sanitized: Literal[True], **match: str) -> list[SanitizedTokenRecord]: ... - @overload - def find(self, sanitized: Literal[False], **match: str) -> list[TokenRecord]: ... - def find(self, sanitized: bool = True, **match: str): - """Retrieve a list of matching tokens. - - This is intended for session retrieval by user and/or client, - and not meant for authentication. - For security reasons, the token id, access_token and expiry - are stripped by default. Pass `sanitized=False` to get full records. - """ - if schema.CAMPUS_KEY in match: - raise ValueError( - "'id=' keyword argument in find() by id is not allowed.\n" - "use get() instead." - ) - if sanitized: - tokens = [ - SanitizedTokenRecord.from_dict(token) - for token in self.storage.get_matching(match) - ] - else: - tokens = [ - TokenRecord.from_dict(token) - for token in self.storage.get_matching(match) - ] - return tokens - - def get_by_client_user(self, client_id: str, user_id: str) -> TokenRecord: - """Get the token for a client/user pair. Returns None if not found.""" - results = self.find(sanitized=False, client_id=client_id, user_id=user_id) - if len(results) == 0: - raise api_errors.NotFoundError( - message="Token not found for this client and user", - client_id=client_id, - user_id=user_id - ) - elif len(results) > 1: - raise api_errors.InternalError( - message="Multiple tokens found for this client and user", - client_id=client_id, - user_id=user_id - ) - token = results[0] - assert isinstance(token, TokenRecord) - return token - - def get(self, token_id: schema.CampusID) -> TokenRecord: - """Retrieve a token from the database by its ID.""" - token_record = self.storage.get_by_id(token_id) - token = TokenRecord.from_dict(token_record) - return token - - def new( - self, - token_data: TokenNew, - *, - expiry_seconds: int = DEFAULT_EXPIRY_SECONDS - ) -> TokenRecord: - """Create a new token in the database.""" - token = TokenRecord.from_dict(dict(token_data)) - token.expires_at = schema.DateTime.utcafter( - token.created_at, seconds=expiry_seconds - ) - try: - self.storage.insert_one(token.to_dict()) - except storage_errors.ConflictError: - raise api_errors.ConflictError( - message="Token already exists for this user and client", - client_id=token_data["client_id"], - user_id=token_data["user_id"] - ) from None - return token diff --git a/campus/models/user.py b/campus/models/user.py deleted file mode 100644 index 623921a1..00000000 --- a/campus/models/user.py +++ /dev/null @@ -1,137 +0,0 @@ -"""campus.models.user - -This module provides classes for managing Campus users. -""" -from typing import NotRequired, TypedDict, Unpack - -from campus.common import devops, schema -from campus.common.errors import api_errors -from campus.common.utils import uid -from campus.common import devops -from campus.models.base import BaseRecordDict -from campus.storage import ( - errors as storage_errors, - get_table -) - -TABLE = "users" - - -@devops.block_env(devops.PRODUCTION) -def init_db(): - """Initialize the tables needed by the model. - - This function is intended to be called only in a test environment (using a - local-only db like SQLite), or in a staging environment before upgrading to - production. - """ - storage = get_table(TABLE) - schema = f""" - CREATE TABLE IF NOT EXISTS "{TABLE}" ( - id TEXT PRIMARY KEY NOT NULL, - email TEXT NOT NULL, - name TEXT NOT NULL, - created_at TEXT NOT NULL, - activated_at TEXT DEFAULT NULL, - UNIQUE(email) - ) - """ - storage.init_table(schema) - - -class UserNew(TypedDict, total=True): - """Request body schema for a users.new operation.""" - email: str - name: str - - -class UserUpdate(TypedDict, total=False): - """Request body schema for a users.update operation.""" - # Currently nothing for the user to update yet - - -class UserResourceDict(UserNew, BaseRecordDict, TypedDict, total=True): - """Response body schema representing the result of a users.get operation.""" - activated_at: NotRequired[schema.DateTime] - - -class User: - """User model for handling database operations related to users.""" - - def __init__(self): - """Initialize the User model with a table storage interface.""" - self.storage = get_table(TABLE) - - def activate(self, email: str) -> None: - """Actions to perform upon first sign-in.""" - user_id = uid.generate_user_uid(email) - try: - self.storage.update_by_id( - user_id, {'activated_at': schema.DateTime.utcnow()}) - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="User not found", - user_id=user_id - ) from None - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - - def new(self, **fields: Unpack[UserNew]) -> UserResourceDict: - """Create a new user.""" - user_id = uid.generate_user_uid(fields["email"]) - record = dict( - id=user_id, - created_at=schema.DateTime.utcnow(), - **fields, - # do not activate user on creation - ) - try: - self.storage.insert_one(record) - except storage_errors.ConflictError as e: - raise api_errors.ConflictError( - message="User already exists", - user_id=user_id, - error=e - ) from None - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - else: - return record # type: ignore - - def delete(self, user_id: str) -> None: - """Delete a user by id.""" - try: - self.storage.delete_by_id(user_id) - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="User not found", - user_id=user_id - ) from None - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - - def get(self, user_id: str) -> UserResourceDict: - """Get a user by id.""" - try: - user = self.storage.get_by_id(user_id) - except storage_errors.NotFoundError as e: - raise api_errors.NotFoundError( - message="User not found", - user_id=user_id - ) from None - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e - else: - return user # type: ignore - - def update(self, user_id: str, **updates: Unpack[UserUpdate]) -> None: - """Update a user by id.""" - try: - self.storage.update_by_id(user_id, dict(updates)) - except storage_errors.NotFoundError as e: - raise api_errors.ConflictError( - message="User not found", - user_id=user_id - ) from None - except Exception as e: - raise api_errors.InternalError.from_exception(e) from e diff --git a/campus/vault/README.md b/campus/vault/README.md deleted file mode 100644 index 6726a5c5..00000000 --- a/campus/vault/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Campus Vault - -Campus secure secrets management service. - -## Installation - -This subpackage is intended for users who need only the vault service, or for advanced deployments. - -**Recommended installation method:** - -```bash -poetry install -``` - -This will install `campus-suite-vault` and all dependencies in a Poetry-managed virtual environment. - -> **Note:** Use `poetry install` for development and deployment. Ensure you are in the correct directory for the subpackage you wish to install. - -## Usage - -After installation, you can import Campus vault modules as needed: - -```python -from campus.vault import Vault, get_vault -``` - -## Not for Standalone Use - -This package is not intended to be used standalone by most users. For a full Campus deployment, use the `campus` meta-package (the root of the repository). diff --git a/campus/vault/__init__.py b/campus/vault/__init__.py deleted file mode 100644 index 4264b1f9..00000000 --- a/campus/vault/__init__.py +++ /dev/null @@ -1,124 +0,0 @@ -"""campus.vault - -Vault service for managing secrets and sensitive system data in Campus. - -Each vault is identified by a unique label and stores key-value pairs of secrets. -Client access to vault labels is controlled through bitflag permissions. -Clients are identified and authenticated using CLIENT_ID and CLIENT_SECRET environment variables. - -DATABASE ACCESS: -This service uses direct PostgreSQL connectivity instead of the storage module -to avoid circular dependencies. Since other services may depend on vault for -secrets management, vault must be independent of the storage layer. The vault -connects directly to PostgreSQL using the VAULTDB_URI environment variable. - -CLIENT AUTHENTICATION: -The vault service maintains its own client storage system to avoid circular -dependencies with the main client model. Vault clients are stored in the -vault_clients table and authenticated using client ID and secret pairs. - -Both CLIENT_ID and CLIENT_SECRET environment variables must be set: -- CLIENT_ID: Identifies the client making the request -- CLIENT_SECRET: Authenticates the client's identity - -PERMISSION SYSTEM: -The vault uses bitflag permissions to control what operations clients can perform: - -- READ (1): Can retrieve existing secrets with vault.get() -- CREATE (2): Can add new secrets with vault.set() (for new keys) -- UPDATE (4): Can modify existing secrets with vault.set() (for existing keys) -- DELETE (8): Can remove secrets with vault.delete() - -Permissions can be combined using the | operator: -- READ | CREATE: Can read and create, but not update or delete -- READ | UPDATE: Can read and modify existing secrets -- ALL: Can perform all operations (READ | CREATE | UPDATE | DELETE) - -ARCHITECTURE: -This module follows separation of concerns: -- model.py: Pure data access layer (no auth/permissions) -- auth.py: Authentication and authorization utilities -- routes/: HTTP routes with auth decorators organized by function - - routes/vault.py: Secret management endpoints - - routes/access.py: Access control endpoints - - routes/client.py: Client management endpoints -- access.py: Permission checking logic -- client.py: Client management -- db.py: Database utilities - -USAGE EXAMPLE: - # Create vault client (typically done by admin) - from vault.client import create_client - client_resource, client_secret = create_client( - name="my-app", - description="My application" - ) - - # Grant permissions (typically done by admin) - from vault.access import grant_access, READ, CREATE - grant_access(client_resource["id"], "api-secrets", READ | CREATE) - - # Use vault programmatically (CLIENT_ID and CLIENT_SECRET env vars must be set) - vault = get_vault("api-secrets") - vault.set("api_key", "secret123") # Requires CREATE (new key) - secret = vault.get("api_key") # Requires READ - vault.set("api_key", "newsecret") # Requires UPDATE (existing key) - vault.delete("api_key") # Requires DELETE - - # Use vault via HTTP API - # POST /vault/api-secrets/my_key with {"value": "secret123"} - # GET /vault/api-secrets/my_key - # DELETE /vault/api-secrets/my_key -""" - -__all__ = [ - "get_vault", - "init_app", - "init_db", - "access", - "client", -] - -from flask import Blueprint, Flask - -from campus.common import devops, errors - -from . import access, client, vault -from .vault import get_vault - - -# This file uses local imports to avoid polluting global space -# pylint: disable=import-outside-toplevel - - -def init_app(app: Flask | Blueprint) -> None: - """Initialize the vault blueprints with the given Flask app. - - This function sets up the vault service routes and blueprints. - - Note: For creating new Flask applications, use the recommended pattern: - from campus.common.devops.deploy import create_app - import campus.vault - app = create_app(campus.vault) - - This ensures proper error handling and deployment configuration. - """ - bp = Blueprint('vault_v1', __name__, url_prefix='/api/v1') - from . import routes - routes.vaults.init_app(bp) - routes.access.init_app(bp) - routes.clients.init_app(bp) - app.register_blueprint(bp) - - -@devops.block_env(devops.PRODUCTION) -@devops.confirm_action_in_env(devops.STAGING) -def init_db(): - """Initialize the tables needed by the model. - - This function is intended to be called only in a test or staging - environment. - """ - vault.init_db() - client.init_db() - access.init_db() diff --git a/campus/vault/access.py b/campus/vault/access.py deleted file mode 100644 index e4609216..00000000 --- a/campus/vault/access.py +++ /dev/null @@ -1,233 +0,0 @@ -"""campus.vault.access - -Access control module for the vault service. - -Manages client permissions for vault labels using bitflag permissions. - -BITFLAGS EXPLAINED: -Bitflags are a way to store multiple boolean permissions in a single integer. -Each permission is represented by a power of 2 (1, 2, 4, 8, 16, ...). - -Our permission system uses these values: -- READ = 1 (binary: 0001) -- CREATE = 2 (binary: 0010) -- UPDATE = 4 (binary: 0100) -- DELETE = 8 (binary: 1000) - -To combine permissions, we use the bitwise OR operator (|): -- READ + CREATE = 1 | 2 = 3 (binary: 0011) -- READ + UPDATE = 1 | 4 = 5 (binary: 0101) -- ALL permissions = 1 | 2 | 4 | 8 = 15 (binary: 1111) - -To check if a permission is granted, we use the bitwise AND operator (&): -- If (granted_permissions & required_permission) == required_permission, access is granted -- Example: granted=5 (READ+UPDATE), checking for READ: (5 & 1) == 1 ✓ -- Example: granted=5 (READ+UPDATE), checking for CREATE: (5 & 2) == 2 ✗ - -This allows efficient storage and checking of multiple permissions in one integer. -""" - -__all__ = [ - "ALL", - "CREATE", - "DELETE", - "READ", - "UPDATE", - "convert_perms_to_access", - "grant_access", - "has_access", - "init_db", - "revoke_access", -] - -from campus.common import devops, schema -from campus.common.errors import api_errors -from campus.common.utils import uid - -from . import db - -TABLE = "vault_access" - -# Access permission bitflags -# Each permission is a power of 2, allowing them to be combined with | (OR) -READ = 1 # 0001 in binary - Can read existing secrets -CREATE = 2 # 0010 in binary - Can create new secrets -UPDATE = 4 # 0100 in binary - Can modify existing secrets -DELETE = 8 # 1000 in binary - Can delete secrets -# 1111 in binary - All permissions (value: 15) -ALL = READ | CREATE | UPDATE | DELETE - - -@devops.block_env(devops.PRODUCTION) -def init_db(): - """Initialize the access control table. - - This function is intended to be called only in a test or staging - environment. - """ - with db.get_connection_context() as conn: - with conn.cursor() as cursor: - # Create vault_access table - access_schema = f""" - CREATE TABLE IF NOT EXISTS vault_access ( - {schema.CAMPUS_KEY} TEXT PRIMARY KEY, - created_at TEXT NOT NULL, - client_id TEXT NOT NULL, - label TEXT NOT NULL, - access INTEGER NOT NULL DEFAULT 0, - UNIQUE(client_id, label) - ) - """ - cursor.execute(access_schema) - - -def convert_perms_to_access(permissions: int | list[str]) -> int: - """Convert permissions given as an integer or list of strings - to an access value integer. - """ - match permissions: - case list(): - # Convert permission names to bitflags - permission_map = { - "READ": READ, - "CREATE": CREATE, - "UPDATE": UPDATE, - "DELETE": DELETE, - "ALL": ALL - } - access_flags = 0 - invalid_perms = [ - perm for perm in permissions - if perm not in permission_map - ] - if invalid_perms: - raise api_errors.InvalidRequestError( - "Invalid permissions provided", - invalid_perms=invalid_perms - ) - access_flags = 0 - for perm in permissions: - access_flags |= permission_map[perm] - case int(): - if not READ <= permissions <= ALL: - raise api_errors.InvalidRequestError( - "Invalid permissions range", - accepted_range="1 - 15" - ) - access_flags = permissions - case _: - raise api_errors.InvalidRequestError( - "Invalid permissions argument type", - given_type=type(permissions).__name__, - required_type="integer | array[string]" - ) - return access_flags - - -def grant_access(client_id: str, label: str, access: int) -> None: - """Grant a client access to a vault label with specified permissions. - - The access parameter uses bitflags to specify which operations are allowed. - You can combine multiple permissions using the | (OR) operator. - - Args: - client_id: The client identifier - label: The vault label - access: Bitflag permissions (default: ALL permissions) - Examples: - - READ: Only read secrets - - READ | CREATE: Read and create new secrets - - READ | UPDATE: Read and modify existing secrets - - ALL: All permissions (READ | CREATE | UPDATE | DELETE) - - Examples: - grant_access("client-123", "api-keys", READ) # Read-only access - grant_access("client-456", "api-keys", READ | CREATE) # Read + create - grant_access("admin-789", "api-keys", ALL) # Full access - """ - try: - with db.get_connection_context() as conn: - # Check if access already exists - existing_access = db.execute_query( - conn, - "SELECT * FROM vault_access WHERE client_id = %s AND label = %s", - (client_id, label), - fetch_one=True - ) - if existing_access: - # Update existing access permissions - db.execute_query( - conn, - "UPDATE vault_access SET access = %s WHERE id = %s", - (access, existing_access[schema.CAMPUS_KEY]), - fetch_one=False, - fetch_all=False - ) - else: - # Create new access record - access_id = uid.generate_category_uid(TABLE, length=16) - db.execute_query( - conn, - ( - "INSERT INTO vault_access (id, created_at, client_id, label, access)" - "VALUES (%s, %s, %s, %s, %s)" - ), - (access_id, schema.DateTime.utcnow(), client_id, label, access), - fetch_one=False, - fetch_all=False - ) - except db.psycopg2.IntegrityError: - raise api_errors.ConflictError( - message="Access for this client and label already exists.") - - -def revoke_access(client_id: str, label: str) -> None: - """Revoke a client's access to a vault label.""" - with db.get_connection_context() as conn: - db.execute_query( - conn, - "DELETE FROM vault_access WHERE client_id = %s AND label = %s", - (client_id, label), - fetch_one=False, - fetch_all=False - ) - - -def has_access(client_id: str, label: str, required_access: int) -> bool: - """Check if a client has the required access permissions for a vault label. - - This function uses bitwise AND (&) to check if the client's granted permissions - include all the required permissions. For example: - - Client has READ | CREATE (value: 3) - - We check for READ permission (value: 1) - - Check: (3 & 1) == 1 → True (client has READ access) - - We check for DELETE permission (value: 8) - - Check: (3 & 8) == 8 → False (client lacks delete access) - - Args: - client_id: The client identifier - label: The vault label - required_access: Required permission bitflags (default: READ) - Can be a single permission or combined permissions. - Examples: - - READ: Check if client can read - - READ | UPDATE: Check if client can both read AND update - - Returns: - True if the client has ALL the required permissions, False otherwise - - Examples: - has_access("client-123", "secrets", READ) # Can client read? - has_access("client-123", "secrets", READ | UPDATE) # Can client read AND update? - """ - with db.get_connection_context() as conn: - access_record = db.execute_query( - conn, - "SELECT access FROM vault_access WHERE client_id = %s AND label = %s", - (client_id, label), - fetch_one=True - ) - if not access_record: - return False - granted_access = access_record["access"] - return (granted_access & required_access) == required_access diff --git a/campus/vault/auth.py b/campus/vault/auth.py deleted file mode 100644 index dd8e21dc..00000000 --- a/campus/vault/auth.py +++ /dev/null @@ -1,308 +0,0 @@ -"""campus.vault.auth - -Authentication and authorization utilities for vault routes. - -This module provides utilities for authenticating clients and checking -permissions at the route level, separating these concerns from the data model. -""" - -import logging -from functools import wraps -from typing import Tuple - -from flask import request, jsonify, g - -from campus.common.errors import api_errors - -from . import access, client - -# Set up detailed logging for authentication debugging -auth_logger = logging.getLogger('campus.vault.auth') -auth_logger.setLevel(logging.DEBUG) - - -def get_client_credentials() -> Tuple[str, str]: - """Get client credentials from request headers or environment. - - First checks for Authorization header with Basic or Bearer token format, - then falls back to environment variables. - - Returns: - Tuple of (client_id, client_secret) - - Raises: - ClientAuthenticationError: If credentials are missing or invalid - """ - auth_logger.debug("🔍 AUTH: Starting credential extraction") - auth_logger.debug(f"🔍 AUTH: Request path: {request.path}") - auth_logger.debug(f"🔍 AUTH: Request method: {request.method}") - - # Check for Authorization header first - auth_header = request.headers.get('Authorization') - auth_logger.debug( - f"🔍 AUTH: Authorization header present: {bool(auth_header)}") - - if auth_header: - auth_logger.debug(f"🔍 AUTH: Auth header type: {auth_header[:20]}...") - if auth_header.startswith('Basic '): - # Handle Basic authentication - auth_logger.debug("🔍 AUTH: Processing Basic authentication") - from campus.common.utils.secret import decode_http_basic_auth - try: - client_id, client_secret = decode_http_basic_auth(auth_header) - if client_id and client_secret: - auth_logger.debug( - f"🔍 AUTH: Basic auth successful - client_id: {client_id}") - return client_id, client_secret - else: - auth_logger.debug( - "🔍 AUTH: Basic auth failed - empty credentials") - except ValueError as e: - auth_logger.debug(f"🔍 AUTH: Basic auth decode error: {e}") - pass # Fall through to other auth methods - elif auth_header.startswith('Bearer '): - # Extract token from Bearer format - auth_logger.debug("🔍 AUTH: Processing Bearer authentication") - token = auth_header[7:] # Remove 'Bearer ' prefix - # For now, expect format: client_id:client_secret (base64 encoded could be added later) - if ':' in token: - client_id, client_secret = token.split(':', 1) - if client_id and client_secret: - auth_logger.debug( - f"🔍 AUTH: Bearer auth successful - client_id: {client_id}") - return client_id, client_secret - else: - auth_logger.debug( - "🔍 AUTH: Bearer auth failed - empty credentials") - else: - auth_logger.debug("🔍 AUTH: Bearer token missing ':' separator") - - # No valid credentials found in Authorization header - auth_logger.error( - "🔍 AUTH: No valid credentials found in Authorization header") - raise api_errors.UnauthorizedError( - message="Authentication required. Provide credentials in Authorization header.") - - -def authenticate_client() -> str: - """Authenticate the client and return the client ID. - - Returns: - The authenticated client ID - - Raises: - ClientAuthenticationError: If authentication fails - """ - auth_logger.debug("🔍 AUTH: Starting client authentication") - try: - client_id, client_secret = get_client_credentials() - auth_logger.debug(f"🔍 AUTH: Got credentials for client: {client_id}") - - # Authenticate using vault's client system - auth_logger.debug("🔍 AUTH: Calling client.authenticate_client") - client.authenticate_client(client_id, client_secret) - auth_logger.debug( - f"🔍 AUTH: Client authentication successful for: {client_id}") - return client_id - except Exception as e: - auth_logger.error(f"🔍 AUTH: Client authentication failed: {e}") - raise - - -def check_vault_access(client_id: str, vault_label: str, required_permission: int) -> None: - """Check if client has required permission for vault label. - - Args: - client_id: The authenticated client ID - vault_label: The vault label to check access for - required_permission: The permission bitflag required (READ, CREATE, UPDATE, DELETE) - - Raises: - VaultAccessDeniedError: If client lacks the required permission - """ - auth_logger.debug( - f"🔍 AUTH: Checking vault access for client {client_id}, label '{vault_label}', permission {required_permission}") - - has_permission = access.has_access( - client_id, vault_label, required_permission) - auth_logger.debug(f"🔍 AUTH: Access check result: {has_permission}") - - if not has_permission: - permission_names = [] - if required_permission & access.READ: - permission_names.append("READ") - if required_permission & access.CREATE: - permission_names.append("CREATE") - if required_permission & access.UPDATE: - permission_names.append("UPDATE") - if required_permission & access.DELETE: - permission_names.append("DELETE") - permission_str = "|".join( - permission_names) if permission_names else str(required_permission) - raise api_errors.ForbiddenError( - message=f"Client '{client_id}' does not have {permission_str} permission for vault '{vault_label}'", client_id=client_id, label=vault_label, permission=permission_str) - - -def require_client_authentication(f): - """Decorator to require client authentication only. - - This decorator: - 1. Authenticates the client - 2. Injects client into the flask g context - - Can be used alone for service-level operations, or combined with - require_vault_permission for vault-specific operations. - - Usage: - # Service-level operations (client management, vault listing) - @require_client_authentication - def create_client(): - # Route implementation - - # Combined with vault permission checking (place this decorator on top) - @require_client_authentication - @require_vault_permission(access.READ) - def get_secret(label, key): - # Route implementation - """ - # Errors are caught by error handler - @wraps(f) - def decorated_function(*args, **kwargs): - auth_logger.debug( - f"🔍 AUTH: Starting authentication for route: {f.__name__}") - auth_logger.debug(f"🔍 AUTH: Route args: {args}, kwargs: {kwargs}") - - try: - client_id = authenticate_client() - auth_logger.debug(f"🔍 AUTH: Client authenticated: {client_id}") - - client_info = client.get_client(client_id) - g.current_client = client_info - auth_logger.debug(f"🔍 AUTH: Client info loaded for {client_id}") - - result = f(*args, **kwargs) - auth_logger.debug( - f"🔍 AUTH: Route {f.__name__} completed successfully") - return result - except Exception as e: - auth_logger.error(f"🔍 AUTH: Route {f.__name__} failed: {e}") - raise - return decorated_function - - -def require_vault_permission(*required_permissions: int): - """Decorator to require vault permission for a route. - - This decorator only checks vault permissions - it expects client_id to already - be available (either injected by @require_client_authentication or passed directly). - - Args: - *required_permissions: One or more permission bitflags. If multiple are provided, - the client needs ANY of them (OR logic), not all. - Examples: - - require_vault_permission(access.READ) - - require_vault_permission(access.CREATE, access.UPDATE) - - Usage: - # Combined with client authentication (place @require_client_authentication on top) - @require_client_authentication() - @require_vault_permission(access.READ) - def get_secret(client_id, label, key): - # Route implementation - - # Multiple permissions (client needs CREATE OR UPDATE) - @require_client_authentication() - @require_vault_permission(access.CREATE, access.UPDATE) - def set_secret(client_id, label, key): - # Route can handle specific CREATE vs UPDATE logic internally - - # Or use standalone if client_id is available through other means - @require_vault_permission(access.READ) - def some_internal_function(client_id, label): - # Route implementation - """ - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - auth_logger.debug( - f"🔍 AUTH: Starting vault permission check for route: {f.__name__}") - auth_logger.debug( - f"🔍 AUTH: Required permissions: {required_permissions}") - auth_logger.debug(f"🔍 AUTH: Route kwargs: {kwargs}") - - try: - # Extract client_id (should be injected by require_client_authentication) - client_id = kwargs.get('client_id') - if not client_id: - auth_logger.debug( - "🔍 AUTH: No client_id in kwargs - checking g.current_client") - # Try to get from Flask g context - if hasattr(g, 'current_client') and g.current_client: - client_id = g.current_client.get('id') - auth_logger.debug( - f"🔍 AUTH: Got client_id from g.current_client: {client_id}") - else: - auth_logger.error("🔍 AUTH: No client_id available") - return jsonify({"error": "Client authentication required"}), 401 - - # Extract vault label from route parameters - vault_label = kwargs.get('label') - auth_logger.debug( - f"🔍 AUTH: Vault label from kwargs: {vault_label}") - - if not vault_label: - auth_logger.error("🔍 AUTH: No vault label in kwargs") - return jsonify({"error": "Vault label required"}), 400 - - # Check if client has ANY of the required permissions (OR logic) - has_any_permission = False - for permission in required_permissions: - auth_logger.debug( - f"🔍 AUTH: Checking permission {permission} for client {client_id} on label '{vault_label}'") - if access.has_access(client_id, vault_label, permission): - auth_logger.debug( - f"🔍 AUTH: Permission {permission} granted") - has_any_permission = True - break - else: - auth_logger.debug( - f"🔍 AUTH: Permission {permission} denied") - - if not has_any_permission: - auth_logger.error( - f"🔍 AUTH: Access denied - client {client_id} lacks required permissions for vault '{vault_label}'") - # Build permission names for error message - permission_names = [] - for permission in required_permissions: - names = [] - if permission & access.READ: - names.append("READ") - if permission & access.CREATE: - names.append("CREATE") - if permission & access.UPDATE: - names.append("UPDATE") - if permission & access.DELETE: - names.append("DELETE") - permission_names.append( - "|".join(names) if names else str(permission)) - - permission_str = " OR ".join(permission_names) - raise api_errors.ForbiddenError( - message=f"Client '{client_id}' does not have {permission_str} permission for vault '{vault_label}'", - client_id=client_id, label=vault_label, permission=permission_str) - - # Call the route function - return f(*args, **kwargs) - - except api_errors.ForbiddenError as e: - response = jsonify(e.to_dict()) - response.status_code = getattr(e, 'status_code', 403) - return response - except Exception as e: - response = jsonify( - {"message": f"Internal error: {e}", "error_code": "SERVER_ERROR", "details": {}}) - response.status_code = 500 - return response - - return decorated_function - return decorator diff --git a/campus/vault/client.py b/campus/vault/client.py deleted file mode 100644 index eb33b990..00000000 --- a/campus/vault/client.py +++ /dev/null @@ -1,317 +0,0 @@ -"""campus.vault.client - -Client authentication module for the vault service. - -This module provides independent client storage and authentication for the vault -service to avoid circular dependencies with the main storage system. The vault -cannot rely on the main client model because it depends on storage, which may -depend on vault for secrets management. - -This module implements its own client storage using direct database access -to the vault database, maintaining compatibility with the main client schema -where possible. - -SECRET_KEY USAGE: -This module retrieves the SECRET_KEY from the vault itself (from the 'vault' -vault label) on demand. While this creates additional database load, it provides -consistency with the vault-first architecture and eliminates environment -variable dependencies. Performance optimizations (such as API keys) can be addressed when needed. -""" - -from typing import Any, TypedDict, NotRequired, Unpack - -from campus.common import devops, schema -from campus.common.errors import api_errors -from campus.common.utils import secret, uid - -from . import db, vault - -CLIENT_TABLE = "vault_clients" - - -def _get_secret_key() -> str: - """Get the SECRET_KEY from the vault's own vault on demand. - - Returns: - The SECRET_KEY value from the 'vault' vault label - - Raises: - VaultKeyError: If SECRET_KEY is not found in the vault vault - """ - vault_vault = vault.Vault("vault") - return vault_vault.get("SECRET_KEY") - - -class ClientNew(TypedDict, total=True): - """Request body schema for creating a new vault client.""" - name: str - description: str - - -class ClientResource(TypedDict, total=True): - """Response body schema representing a vault client.""" - id: str - name: str - description: str - created_at: schema.DateTime - secret_hash: NotRequired[str] - - -class VaultClientSecretResponse(TypedDict, total=True): - """Response body schema for client secret operations.""" - secret: str - - -@devops.block_env(devops.PRODUCTION) -def init_db(): - """Initialize the vault client table. - - This function is intended to be called only in a test or staging - environment. - """ - with db.get_connection_context() as conn: - with conn.cursor() as cursor: - client_schema = f""" - CREATE TABLE IF NOT EXISTS {CLIENT_TABLE} ( - {schema.CAMPUS_KEY} TEXT PRIMARY KEY, - secret_hash TEXT, - name TEXT NOT NULL, - description TEXT, - created_at TEXT NOT NULL, - UNIQUE (name), - UNIQUE (secret_hash) - ) - """ - cursor.execute(client_schema) - - -def create_client(**fields: Unpack[ClientNew]) -> dict[str, Any]: - """Create a new vault client with authentication credentials. - - Args: - **fields: Client creation fields (name, description) - - Returns: - Tuple of (client_resource, client_secret) - The client_secret should be securely provided to the client. - - Raises: - Exception: If client creation fails (e.g., name already exists) - """ - client_id = uid.generate_category_uid("client", length=12) - client_secret = secret.generate_client_secret() - secret_hash = secret.hash_client_secret( - client_secret, _get_secret_key()) - - record = { - schema.CAMPUS_KEY: client_id, - "created_at": schema.DateTime.utcnow(), - "secret_hash": secret_hash, - **fields, - } - - try: - with db.get_connection_context() as conn: - db.execute_query( - conn, - f""" - INSERT INTO {CLIENT_TABLE} ({schema.CAMPUS_KEY}, secret_hash, name, description, created_at) - VALUES (%s, %s, %s, %s, %s) - """, - (record[schema.CAMPUS_KEY], record["secret_hash"], record["name"], - record["description"], record["created_at"]), - fetch_one=False, - fetch_all=False - ) - except db.psycopg2.IntegrityError: - raise api_errors.ConflictError( - message="Client name already exists." - ) from None - - # Return client resource without secret_hash - client_resource = { - k: v for k, v in record.items() if k != "secret_hash"} - return { - "client": client_resource, - "secret": client_secret - } - - -def get_client(client_id: str) -> dict[str, Any]: - """Retrieve a vault client by its ID. - - Args: - client_id: The client identifier - - Returns: - Client resource without secret_hash - - Raises: - VaultClientAuthenticationError: If client not found - """ - with db.get_connection_context() as conn: - client_record = db.execute_query( - conn, - f"SELECT {schema.CAMPUS_KEY}, name, description, created_at FROM {CLIENT_TABLE} WHERE {schema.CAMPUS_KEY} = %s", - (client_id,), - fetch_one=True - ) - if not client_record: - raise api_errors.NotFoundError( - message=f"Vault client '{client_id}' not found", client_id=client_id) - return client_record - - -def list_clients() -> list[ClientResource]: - """List all vault clients. - - Returns: - List of client resources without secret_hash - """ - with db.get_connection_context() as conn: - client_records = db.execute_query( - conn, - f"SELECT {schema.CAMPUS_KEY}, name, description, created_at FROM {CLIENT_TABLE}", - (), - fetch_all=True - ) - - return client_records or [] - - -def delete_client(client_id: str) -> None: - """Delete a vault client by its ID. - - Args: - client_id: The client identifier - - Raises: - VaultClientAuthenticationError: If client not found - """ - with db.get_connection_context() as conn: - result = db.execute_query( - conn, - f"DELETE FROM {CLIENT_TABLE} WHERE {schema.CAMPUS_KEY} = %s RETURNING *", - (client_id,), - fetch_one=True, - fetch_all=False - ) - if not result: - raise api_errors.NotFoundError( - message=f"Vault client '{client_id}' not found", client_id=client_id) - - -def replace_client_secret(client_id: str) -> str: - """Replace a client's secret with a new one. - - Args: - client_id: The client identifier - - Returns: - The new client secret - - Raises: - VaultClientAuthenticationError: If client not found - """ - # Check if client exists first - get_client(client_id) # This will raise NotFoundError if not found - - new_secret = secret.generate_client_secret() - secret_hash = secret.hash_client_secret( - new_secret, _get_secret_key()) - - with db.get_connection_context() as conn: - db.execute_query( - conn, - f"UPDATE {CLIENT_TABLE} SET secret_hash = %s WHERE {schema.CAMPUS_KEY} = %s", - (secret_hash, client_id), - fetch_one=False, - fetch_all=False - ) - - return new_secret - - -def authenticate_client(client_id: str, client_secret: str) -> None: - """Authenticate a client using their ID and secret. - - Args: - client_id: The client identifier - client_secret: The client secret - - Raises: - UnauthorizedError: If client not found or client secret is invalid - """ - with db.get_connection_context() as conn: - client_record = db.execute_query( - conn, - f"SELECT secret_hash FROM {CLIENT_TABLE} WHERE {schema.CAMPUS_KEY} = %s", - (client_id,), - fetch_one=True - ) - if not client_record: - raise api_errors.UnauthorizedError( - message=f"Invalid credentials", client_id=client_id) - if not client_record["secret_hash"]: - raise api_errors.InternalError( - message=f"Vault client '{client_id}' has no secret configured", client_id=client_id) - expected_hash = secret.hash_client_secret( - client_secret, _get_secret_key()) - if client_record["secret_hash"] != expected_hash: - raise api_errors.UnauthorizedError( - message=f"Invalid secret for '{client_id}'", client_id=client_id) - - -def update_client(client_id: str, **updates: Unpack[ClientNew]) -> None: - """Update a vault client's information. - - Args: - client_id: The client identifier - **updates: Fields to update (name, description) - - Raises: - NotFoundError: If client not found - ConflictError: If updated name conflicts with existing client - """ - if not updates: - return - - # Check if client exists first - get_client(client_id) # This will raise NotFoundError if not found - - # Build dynamic update query - set_clauses = [] - values = [] - - for field, value in updates.items(): - if field in ("name", "description"): - set_clauses.append(f"{field} = %s") - values.append(value) - - if not set_clauses: - return - - values.append(client_id) - - try: - with db.get_connection_context() as conn: - updated_record = db.execute_query( - conn, - ( - f"UPDATE {CLIENT_TABLE} " - f"SET {', '.join(set_clauses)} " - "WHERE id = %s RETURNING *" - ), - tuple(values), - fetch_one=True, - fetch_all=False - ) - except db.psycopg2.IntegrityError: - raise api_errors.ConflictError( - message="Client name already exists." - ) from None - else: - if updated_record is None: - raise api_errors.NotFoundError( - message="Client not found." - ) diff --git a/campus/vault/db.py b/campus/vault/db.py deleted file mode 100644 index 55ee88ab..00000000 --- a/campus/vault/db.py +++ /dev/null @@ -1,114 +0,0 @@ -"""campus.vault.db - -Direct PostgreSQL database access for the vault service. - -This module provides direct PostgreSQL connectivity to avoid circular dependencies -with the storage module. The vault needs to be independent since other services -may depend on it for secrets management. - -Environment Variables: -- VAULTDB_URI: PostgreSQL connection string for vault database (required) - -Usage: - from vault.db import get_connection, execute_query - - with get_connection() as conn: - results = execute_query(conn, "SELECT * FROM vault WHERE label = %s", ("api-keys",)) -""" - -import os -from contextlib import contextmanager -from typing import Generator, Any, Optional - -import psycopg2 -from psycopg2.extras import RealDictCursor - - -def get_connection() -> psycopg2.extensions.connection: - """Get a PostgreSQL connection using the VAULTDB_URI environment variable. - - Returns: - A psycopg2 connection object with autocommit disabled - - Raises: - ValueError: If VAULTDB_URI environment variable is not set - psycopg2.Error: If connection to database fails - """ - vault_db_uri = os.environ.get("VAULTDB_URI") - if not vault_db_uri: - raise ValueError("VAULTDB_URI environment variable is required") - - conn = psycopg2.connect(vault_db_uri) - conn.autocommit = False - return conn - - -@contextmanager -def get_connection_context() -> Generator[psycopg2.extensions.connection, None, None]: - """Context manager for PostgreSQL connections. - - Automatically handles connection cleanup and provides transaction management. - Commits on successful completion, rolls back on exceptions. - - Yields: - psycopg2 connection object - - Example: - with get_connection_context() as conn: - with conn.cursor() as cursor: - cursor.execute("INSERT INTO vault ...") - # Automatically commits on success - """ - conn = get_connection() - try: - yield conn - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() - - -def execute_query( - conn: psycopg2.extensions.connection, - query: str, - params: tuple = (), - fetch_one: bool = False, - fetch_all: bool = True -) -> Optional[Any]: - """Execute a SQL query with parameters and return results. - - Args: - conn: PostgreSQL connection object - query: SQL query string with %s placeholders - params: Tuple of parameters for the query - fetch_one: If True, return single row (or None) - fetch_all: If True, return all rows as list (default) - - Returns: - - If fetch_one=True: Single row dict or None - - If fetch_all=True: List of row dicts (can be empty) - - If both False: None (for INSERT/UPDATE/DELETE operations) - - Example: - # Get single record - user = execute_query(conn, "SELECT * FROM vault WHERE CAMPUS_KEY = %s", ("123",), fetch_one=True) - - # Get multiple records - secrets = execute_query(conn, "SELECT * FROM vault WHERE label = %s", ("api-keys",)) - - # Insert/Update (no return value needed) - execute_query(conn, "INSERT INTO vault ...", (...,), fetch_one=False, fetch_all=False) - """ - with conn.cursor(cursor_factory=RealDictCursor) as cursor: - cursor.execute(query, params) - - if fetch_one: - row = cursor.fetchone() - return dict(row) if row else None - elif fetch_all: - rows = cursor.fetchall() - return [dict(row) for row in rows] - else: - return None diff --git a/campus/vault/routes/__init__.py b/campus/vault/routes/__init__.py deleted file mode 100644 index 05c883a1..00000000 --- a/campus/vault/routes/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""campus.vault.routes - -Flask blueprint modules for the vault service. - -This package contains all HTTP route definitions organized by functionality: -- vault.py: Secret management operations (/vault/*) -- access.py: Access control operations (/access/*) -- client.py: Client management operations (/client/*) - -Each module defines a Flask blueprint with appropriate URL prefixes and -authentication decorators. -""" - -__all__ = [ - "access", - "clients", - "vaults" -] - -from . import access, clients, vaults diff --git a/campus/vault/routes/access.py b/campus/vault/routes/access.py deleted file mode 100644 index aef99231..00000000 --- a/campus/vault/routes/access.py +++ /dev/null @@ -1,143 +0,0 @@ -"""campus.vault.routes.access - -Flask routes for vault access control management. - -These routes handle granting, revoking, and checking access permissions for vault clients. -Admin operations require ALL permissions, access checking requires READ permissions. -""" - -from typing import TypedDict - -from flask import Blueprint, Flask - -from campus.common.errors import api_errors -import campus.common.validation.flask as flask_validation - -from .. import access -from ..auth import require_client_authentication, require_vault_permission - -# Create blueprint for access management routes -bp = Blueprint('access', __name__, url_prefix='/access') - - -def init_app(app: Flask | Blueprint) -> None: - """Initialize the access routes with the given Flask app or blueprint.""" - app.register_blueprint(bp) - - -class GetVaultAccess(TypedDict): - """Schema for a request to get access.""" - client_id: str - - -class GrantVaultAccess(TypedDict): - """Schema for a request to grant access.""" - client_id: str - permissions: list[str] | int - - -class RevokeVaultAccess(TypedDict): - """Schema for a request to revoke access.""" - client_id: str - - -@bp.post("/