diff options
| -rw-r--r-- | generalresearch/gr_django/migrations/0004_user_avatar_url_user_name_alter_user_sub.py | 28 | ||||
| -rw-r--r-- | generalresearch/gr_django/models.py | 8 | ||||
| -rw-r--r-- | generalresearch/managers/gr/authentication.py | 125 | ||||
| -rw-r--r-- | generalresearch/models/gr/authentication.py | 103 |
4 files changed, 75 insertions, 189 deletions
diff --git a/generalresearch/gr_django/migrations/0004_user_avatar_url_user_name_alter_user_sub.py b/generalresearch/gr_django/migrations/0004_user_avatar_url_user_name_alter_user_sub.py new file mode 100644 index 0000000..586b5f6 --- /dev/null +++ b/generalresearch/gr_django/migrations/0004_user_avatar_url_user_name_alter_user_sub.py @@ -0,0 +1,28 @@ +# Generated by Django 6.1.1 on 2026-10-27 18:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0003_user_email_alter_membership_owner_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='avatar_url', + field=models.URLField(null=True), + ), + migrations.AddField( + model_name='user', + name='name', + field=models.CharField(max_length=200, null=True), + ), + migrations.AlterField( + model_name='user', + name='sub', + field=models.CharField(max_length=200, null=True), + ), + ] diff --git a/generalresearch/gr_django/models.py b/generalresearch/gr_django/models.py index 7f26eaf..7d2b7b1 100644 --- a/generalresearch/gr_django/models.py +++ b/generalresearch/gr_django/models.py @@ -5,8 +5,10 @@ from django.db import models class User(models.Model): id = models.BigAutoField(primary_key=True) - sub = models.CharField(max_length=200, blank=False, null=False) + sub = models.CharField(max_length=200, blank=False, null=True) email = models.EmailField(null=True, unique=True) + name = models.CharField(max_length=200, null=True) + avatar_url = models.URLField(null=True) is_superuser = models.BooleanField(default=False) date_joined = models.DateTimeField(auto_now_add=True) @@ -50,11 +52,11 @@ class Membership(models.Model): class Meta: constraints = [ models.UniqueConstraint( - fields=['user_id', 'team_id'], - name='uniq_membership_user_team' + fields=["user_id", "team_id"], name="uniq_membership_user_team" ) ] + class Team(models.Model): """ This is a collection of users / accounts that work as a team and share diff --git a/generalresearch/managers/gr/authentication.py b/generalresearch/managers/gr/authentication.py index 18c8ba4..8e3b84e 100644 --- a/generalresearch/managers/gr/authentication.py +++ b/generalresearch/managers/gr/authentication.py @@ -4,29 +4,27 @@ import binascii import logging import os from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from psycopg import sql -from pydantic import AnyHttpUrl, PositiveInt +from pydantic import PositiveInt from generalresearch.managers.base import PostgresManager, PostgresManagerWithRedis from generalresearch.models.custom_types import UUIDStr if TYPE_CHECKING: - from generalresearch.models.gr.authentication import GRToken, GRUser from generalresearch.pg_helper import PostgresConfig - from generalresearch.redis_helper import RedisConfig LOG = logging.getLogger("gr") class GRUserManager(PostgresManagerWithRedis): - def create( self, - sub: str, - email: str | None = None, + email: str, + name: str, + avatar_url: str | None = None, is_superuser: bool = False, ) -> GRUser: from generalresearch.models.gr.authentication import GRUser @@ -35,8 +33,9 @@ class GRUserManager(PostgresManagerWithRedis): instance = GRUser.model_validate( { - "sub": sub, "email": email, + "name": name, + "avatar_url": avatar_url, "is_superuser": is_superuser, "date_joined": now, } @@ -46,10 +45,12 @@ class GRUserManager(PostgresManagerWithRedis): with self.pg_config.make_connection() as conn: with conn.cursor() as c: query = sql.SQL(""" - INSERT INTO gr_user - (sub, email, is_superuser, date_joined) - VALUES (%(sub)s, %(email)s, %(is_superuser)s, %(date_joined)s) - RETURNING id + INSERT INTO gr_user ( + email, name, is_superuser, date_joined, avatar_url + ) VALUES ( + %(email)s, %(name)s, %(is_superuser)s, + %(date_joined)s, %(avatar_url)s + ) RETURNING id; """) c.execute(query=query, params=data) gr_user_id: int = c.fetchone()["id"] @@ -62,7 +63,6 @@ class GRUserManager(PostgresManagerWithRedis): self, *, gr_user_id: int | None = None, - sub: str | None = None, email: str | None = None, ) -> list[GRUser]: from generalresearch.models.gr.authentication import GRUser @@ -72,19 +72,16 @@ class GRUserManager(PostgresManagerWithRedis): if gr_user_id is not None: conditions.append("u.id = %(gr_user_id)s") - params['gr_user_id'] = gr_user_id - if sub is not None: - conditions.append("u.sub = %(sub)s") - params['sub'] = sub + params["gr_user_id"] = gr_user_id if email is not None: conditions.append("u.email = %(email)s") - params['email'] = email + params["email"] = email filter_str = " AND ".join(conditions) filter_str = "WHERE " + filter_str if filter_str else "" query = f""" - SELECT u.* + SELECT u.id, u.name, u.email, u.is_superuser, u.date_joined, u.avatar_url FROM gr_user AS u {filter_str} """ @@ -108,26 +105,11 @@ class GRUserManager(PostgresManagerWithRedis): def get_by_id(self, gr_user_id: int) -> GRUser | None: return self._get_one(gr_user_id=gr_user_id) - def get_by_sub(self, sub: str, raises=True) -> GRUser | None: - return self._get_one(sub=sub, raises=raises) - def get_by_email(self, email: str, raises=True) -> GRUser | None: return self._get_one(email=email, raises=raises) - def get_by_sub_or_create(self, sub: str) -> GRUser: - return self.get_by_sub(sub=sub, raises=False) or self.create(sub=sub) - def get_all(self) -> list[GRUser]: - from generalresearch.models.gr.authentication import GRUser - - with self.pg_config.make_connection() as conn, conn.cursor() as c: - c.execute(query=""" - SELECT u.* - FROM gr_user AS u - """) - res = c.fetchall() - - return [GRUser.from_postgresql(i) for i in res] + return self.filter() def get_by_team(self, team_id: PositiveInt) -> list[GRUser]: from generalresearch.models.gr.authentication import GRUser @@ -135,21 +117,17 @@ class GRUserManager(PostgresManagerWithRedis): with self.pg_config.make_connection() as conn, conn.cursor() as c: c.execute( query=""" - SELECT gru.* + SELECT u.id, u.name, u.email, u.avatar_url, + u.is_superuser, u.date_joined FROM common_membership AS membership - INNER JOIN gr_user AS gru - ON gru.id = membership.user_id + INNER JOIN gr_user AS u + ON u.id = membership.user_id WHERE membership.team_id = %s """, params=(team_id,), ) res = c.fetchall() - for item in res: - for k, v in item.items(): - if isinstance(v, datetime): - item[k] = item[k].replace(tzinfo=UTC) - return [GRUser.model_validate(item) for item in res] def list_product_uuids( @@ -171,14 +149,9 @@ class GRUserManager(PostgresManagerWithRedis): class GRTokenManager(PostgresManager): - def get_by_key( self, api_key: str, - jwks: dict[str, Any] | None = None, - audience: str | None = None, - issuer: AnyHttpUrl | str | None = None, - gr_redis_config: RedisConfig | None = None, ) -> GRToken: """Return the GRToken for this API Token. @@ -191,46 +164,16 @@ class GRTokenManager(PostgresManager): :return GRToken instance (minified version, no relationships) :raises NotFoundException """ - from generalresearch.models.gr.authentication import Claims, GRToken - - # SSO Key - if GRToken.is_sso(api_key): - from jose import jwt - - payload = jwt.decode( - token=api_key, - key=jwks, - algorithms=["RS256"], - audience=audience, - issuer=issuer, - ) - claims = Claims.model_validate(payload) - - gr_um = GRUserManager( - pg_config=self.pg_config, redis_config=gr_redis_config - ) - gr_user = gr_um.get_by_sub_or_create(sub=claims.subject) - gr_user.claims = claims - - gr_token = GRToken.model_validate( - { - "key": api_key, - "user_id": gr_user.id, - "user": gr_user, - "created": datetime.now(tz=UTC), - } - ) - - return gr_token + from generalresearch.models.gr.authentication import GRToken # API Key with self.pg_config.make_connection() as conn, conn.cursor() as c: query = sql.SQL(""" - SELECT grk.* - FROM gr_token AS grk - WHERE grk.key = %s - LIMIT 1 - """) + SELECT grk.* + FROM gr_token AS grk + WHERE grk.key = %s + LIMIT 2 + """) c.execute(query=query, params=(api_key,)) res = c.fetchall() @@ -240,9 +183,7 @@ class GRTokenManager(PostgresManager): if len(res) > 1: raise ValueError(f"Too many GRUsers found with token of '{api_key}'") - item = res[0] - - return GRToken.model_validate(item) + return GRToken.model_validate(res[0]) def create(self, user_id: PositiveInt) -> None: # Taken directly from the DRF Token @@ -285,18 +226,10 @@ class GRTokenManager(PostgresManager): WHERE u.id = %s LIMIT 1; """) - c.execute(query=query, params=(user_id,)) - result = c.fetchall() if not result: return None - res = result[0] - - for k in res: - if isinstance(res[k], datetime): - res[k] = res[k].replace(tzinfo=UTC) - - return GRToken.model_validate(res) + return GRToken.model_validate(result[0]) diff --git a/generalresearch/models/gr/authentication.py b/generalresearch/models/gr/authentication.py index 5366db8..3c7c461 100644 --- a/generalresearch/models/gr/authentication.py +++ b/generalresearch/models/gr/authentication.py @@ -7,17 +7,15 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING, Any from pydantic import ( - AnyHttpUrl, BaseModel, ConfigDict, EmailStr, Field, - NonNegativeInt, PositiveInt, field_validator, ) -from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr +from generalresearch.models.custom_types import AwareDatetimeISO, HttpsUrlStr, UUIDStr if TYPE_CHECKING: from generalresearch.managers.gr.business import BusinessManager @@ -30,76 +28,10 @@ if TYPE_CHECKING: from generalresearch.redis_helper import RedisConfig -class Claims(BaseModel): - iss: str | None = Field( - default=None, - description="Issuer: https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1", - ) - - sub: str | None = Field( - default=None, - description="Subject: https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.2", - ) - - aud: str | None = Field( - default=None, - description="Audience: https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3", - ) - - exp: NonNegativeInt | None = Field( - default=None, - description="Expiration time: https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4", - ) - - iat: NonNegativeInt | None = Field( - default=None, - description="Issued at: https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6", - ) - - auth_time: NonNegativeInt | None = Field( - default=None, - description="When authentication occured: https://openid.net/specs/openid-connect-core-1_0.html#IDToken", - ) - - acr: str | None = Field( - default=None, - description="Authentication Context Class Reference: https://openid.net/specs/openid-connect-core-1_0.html#IDToken", - ) - - amr: list[str] | None = Field( - default=None, - description="Authentication Methods References: https://openid.net/specs/openid-connect-core-1_0.html#IDToken", - ) - - c_hash: str | None = Field( - default=None, - description="Code hash value: http://openid.net/specs/openid-connect-core-1_0.html", - ) - - nonce: str | None = Field( - default=None, - description="Value used to associate a Client session with an ID Token: http://openid.net/specs/openid-connect-core-1_0.html", - ) - - at_hash: str | None = Field( - default=None, - description="Access Token hash value: http://openid.net/specs/openid-connect-core-1_0.html", - ) - - sid: str | None = Field( - default=None, - description="Session ID: https://openid.net/specs/openid-connect-frontchannel-1_0.html#ClaimsContents", - ) - - # --- Properties --- - - @property - def subject(self): - return self.sub - - class GRUserRef(BaseModel): email: EmailStr | None = Field(default=None) + name: str | None = Field(default=None) + avatar_url: HttpsUrlStr | None = Field(default=None) date_joined: AwareDatetimeISO = Field( description="When the GR User account signed up." ) @@ -113,8 +45,10 @@ class GRUser(BaseModel): ) id: PositiveInt | None = Field(default=None) - sub: str | None = Field(max_length=200) email: EmailStr | None = Field(default=None) + name: str | None = Field(default=None) + avatar_url: HttpsUrlStr | None = Field(default=None) + is_superuser: bool = Field(default=False) date_joined: AwareDatetimeISO = Field( @@ -126,28 +60,15 @@ class GRUser(BaseModel): teams: list[Team] | None = Field(default=None) products: list[Product] | None = Field(default=None) token: GRToken | None = Field(default=None) - claims: Claims | None = Field(default=None) def to_user_ref(self) -> GRUserRef: return GRUserRef( email=self.email, + name=self.name, + avatar_url=self.avatar_url, date_joined=self.date_joined, ) - def prefetch_claims( - self, token: str, key: dict[str, Any], audience: str, issuer: AnyHttpUrl - ) -> None: - from jose import jwt - - payload = jwt.decode( - token=token, - key=key, - algorithms=["RS256"], - audience=audience, - issuer=issuer, - ) - self.claims = Claims.model_validate(payload) - def prefetch_businesses( self, pg_config: PostgresConfig, redis_config: RedisConfig ) -> None: @@ -158,6 +79,7 @@ class GRUser(BaseModel): if self.is_superuser: self.businesses = bm.get_all() else: + assert self.id is not None self.businesses = bm.get_by_user_id(user_id=self.id) def prefetch_teams( @@ -206,6 +128,7 @@ class GRUser(BaseModel): ) tm = GRTokenManager(pg_config=pg_config) + assert self.id is not None self.token = tm.get_by_user_id(user_id=self.id) def __eq__(self, other: GRUser) -> bool: @@ -242,9 +165,9 @@ class GRUser(BaseModel): team_manager: TeamManager | None, product_manager: ProductManager, ) -> list[UUIDStr]: - assert ( - business_manager is not None or team_manager is not None - ), "At least one of business_manager or team_manager is required" + assert business_manager is not None or team_manager is not None, ( + "At least one of business_manager or team_manager is required" + ) return product_manager.get_uuids( business_uuids=( |
