Skip to content

Commit 7602afd

Browse files
authored
26556 - Add in user / membership row on API key creation (#3327)
1 parent 08153ba commit 7602afd

File tree

13 files changed

+141
-23
lines changed

13 files changed

+141
-23
lines changed

auth-api/src/auth_api/models/membership.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from sqlalchemy import Column, ForeignKey, Integer, and_, desc, func
2525
from sqlalchemy.orm import relationship
2626

27-
from auth_api.utils.enums import OrgType, Status
27+
from auth_api.utils.enums import LoginSource, OrgType, Status
2828
from auth_api.utils.roles import ADMIN, COORDINATOR, USER, VALID_ORG_STATUSES, VALID_STATUSES
2929

3030
from .base_model import BaseModel
@@ -101,6 +101,7 @@ def find_members_by_org_id_by_status_by_roles(
101101
.filter(and_(Membership.status == status, Membership.membership_type_code.in_(roles)))
102102
.join(OrgModel)
103103
.filter(OrgModel.id == int(org_id or -1))
104+
.filter(~Membership.user.has(login_source=LoginSource.API_GW.value))
104105
.all()
105106
)
106107

auth-api/src/auth_api/models/user.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,25 @@ def create_from_jwt_token(cls, first_name: str, last_name: str, **kwargs):
144144
return user
145145
return None
146146

147+
@staticmethod
148+
def create_user_for_api_user(username, keycloak_guid):
149+
"""Create user for API user."""
150+
api_user = User(
151+
username=username,
152+
firstname=None,
153+
lastname=username,
154+
email=None,
155+
keycloak_guid=keycloak_guid,
156+
created=datetime.datetime.now(tz=datetime.timezone.utc),
157+
login_source=LoginSource.API_GW.value,
158+
status=UserStatusCode.get_default_type(),
159+
idp_userid=username,
160+
login_time=datetime.datetime.now(tz=datetime.timezone.utc),
161+
type=Role.PUBLIC_USER.name,
162+
verified=True,
163+
).save()
164+
return api_user
165+
147166
@classmethod
148167
@user_context
149168
def update_from_jwt_token(

auth-api/src/auth_api/services/api_gateway.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@
2121
from structured_logging import StructuredLogging
2222

2323
from auth_api.exceptions import BusinessException, Error
24+
from auth_api.models.membership import Membership as MembershipModel
2425
from auth_api.models.org import Org as OrgModel
26+
from auth_api.models.user import User as UserModel
2527
from auth_api.services.authorization import check_auth
28+
from auth_api.services.flags import flags
2629
from auth_api.services.keycloak import KeycloakService
30+
from auth_api.services.membership import Membership as MembershipService
2731
from auth_api.services.rest_service import RestService
2832
from auth_api.utils.api_gateway import generate_client_representation
2933
from auth_api.utils.constants import GROUP_ACCOUNT_HOLDERS, GROUP_API_GW_SANDBOX_USERS, GROUP_API_GW_USERS
34+
from auth_api.utils.enums import Status
3035
from auth_api.utils.roles import ADMIN, STAFF
3136
from auth_api.utils.user_context import UserContext, user_context
3237

@@ -60,6 +65,8 @@ def create_key(cls, org_id: int, request_json: Dict[str, str]):
6065
# If env is sandbox; then create a sandbox payment account.
6166
if env != "prod":
6267
cls._create_payment_account(org)
68+
# Future - if PROD and target is SANDBOX - Call into AUTH-API to create an org, this will call PAY-API
69+
# to create payment account
6370
cls._create_consumer(name, org, env=env)
6471
org.has_api_access = True
6572
org.save()
@@ -74,20 +81,41 @@ def create_key(cls, org_id: int, request_json: Dict[str, str]):
7481
)
7582
response = api_key_response.json()
7683

84+
cls._create_user_and_membership_for_api_user(org_id, env)
7785
return response
7886

87+
@classmethod
88+
def _create_user_and_membership_for_api_user(cls, org_id: int, env: str):
89+
"""Create a user and membership for the api user."""
90+
if flags.is_on("enable-api-gw-user-membership-creation", True) is True:
91+
client_name = ApiGateway.get_api_client_id(org_id, env)
92+
client = KeycloakService.get_service_account_by_client_name(client_name)
93+
if (api_user := UserModel.find_by_username(client_name)) is None:
94+
api_user = UserModel.create_user_for_api_user(client_name, client.get("id"))
95+
if MembershipModel.find_membership_by_user_and_org(api_user.id, org_id) is None:
96+
MembershipService.create_admin_membership_for_api_user(org_id, api_user.id)
97+
7998
@classmethod
8099
def _get_api_gw_key(cls, env):
100+
"""Get the api gateway key."""
81101
logger.info("_get_api_gw_key %s", env)
82102
return current_app.config.get("API_GW_KEY") if env == "prod" else current_app.config.get("API_GW_NON_PROD_KEY")
83103

104+
@staticmethod
105+
def get_api_client_id(org_id, env):
106+
"""Get the client id for the org."""
107+
client_id_pattern = current_app.config.get("API_GW_KC_CLIENT_ID_PATTERN")
108+
suffix = "-sandbox" if env != "prod" else ""
109+
client_id = f"{client_id_pattern}{suffix}".format(account_id=org_id)
110+
return client_id
111+
84112
@classmethod
85113
def _create_consumer(cls, name, org, env):
86114
"""Create an API Gateway consumer."""
87115
consumer_endpoint: str = cls._get_api_consumer_endpoint(env)
88116
gw_api_key = cls._get_api_gw_key(env)
89117
email = cls._get_email_id(org.id, env)
90-
client_rep = generate_client_representation(org.id, current_app.config.get("API_GW_KC_CLIENT_ID_PATTERN"), env)
118+
client_rep = generate_client_representation(org.id, ApiGateway.get_api_client_id(org.id, env))
91119
KeycloakService.create_client(client_rep)
92120
service_account = KeycloakService.get_service_account_user(client_rep.get("id"))
93121

@@ -184,6 +212,7 @@ def _add_key_to_response(_key):
184212

185213
@classmethod
186214
def _get_email_id(cls, org_id, env) -> str:
215+
"""Get the email id for the org."""
187216
if current_app.config.get("API_GW_CONSUMER_EMAIL", None) is not None:
188217
return current_app.config.get("API_GW_CONSUMER_EMAIL")
189218

@@ -243,6 +272,7 @@ def _create_payment_account(cls, org: OrgModel, **kwargs):
243272

244273
@classmethod
245274
def _create_sandbox_pay_account(cls, pay_request, user):
275+
"""Create a sandbox payment account."""
246276
logger.info("Creating Sandbox Payload %s", pay_request)
247277
pay_sandbox_accounts_endpoint = f"{current_app.config.get('PAY_API_SANDBOX_URL')}/accounts?sandbox=true"
248278
RestService.post(
@@ -251,12 +281,14 @@ def _create_sandbox_pay_account(cls, pay_request, user):
251281

252282
@classmethod
253283
def _get_pay_account(cls, org, user):
284+
"""Get the payment account for the org."""
254285
pay_accounts_endpoint = f"{current_app.config.get('PAY_API_URL')}/accounts/{org.id}"
255286
pay_account = RestService.get(endpoint=pay_accounts_endpoint, token=user.bearer_token).json()
256287
return pay_account
257288

258289
@classmethod
259290
def _get_api_consumer_endpoint(cls, env):
291+
"""Get the consumer endpoint for the environment."""
260292
logger.info("_get_api_consumer_endpoint %s", env)
261293
return (
262294
current_app.config.get("API_GW_CONSUMERS_API_URL")

auth-api/src/auth_api/services/keycloak.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,24 @@ def create_client(client_representation: Dict[str, any]):
446446
)
447447
response.raise_for_status()
448448

449+
@staticmethod
450+
def get_service_account_by_client_name(client_name: str):
451+
"""Get client by name."""
452+
config = current_app.config
453+
base_url = config.get("KEYCLOAK_BASE_URL")
454+
realm = config.get("KEYCLOAK_REALMNAME")
455+
timeout = config.get("CONNECT_TIMEOUT", 60)
456+
admin_token = KeycloakService._get_admin_token()
457+
headers = {"Content-Type": ContentType.JSON.value, "Authorization": f"Bearer {admin_token}"}
458+
response = requests.get(
459+
f"{base_url}/auth/admin/realms/{realm}/clients?clientId={client_name}",
460+
headers=headers,
461+
timeout=timeout,
462+
)
463+
response.raise_for_status()
464+
client_id = response.json()[0]["id"]
465+
return KeycloakService.get_service_account_user(client_id)
466+
449467
@staticmethod
450468
def get_service_account_user(client_identifier: str):
451469
"""Return service account user."""
@@ -454,7 +472,6 @@ def get_service_account_user(client_identifier: str):
454472
realm = config.get("KEYCLOAK_REALMNAME")
455473
timeout = config.get("CONNECT_TIMEOUT", 60)
456474
admin_token = KeycloakService._get_admin_token()
457-
458475
headers = {"Content-Type": ContentType.JSON.value, "Authorization": f"Bearer {admin_token}"}
459476
response = requests.get(
460477
f"{base_url}/auth/admin/realms/{realm}/clients/{client_identifier}/service-account-user",

auth-api/src/auth_api/services/membership.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import json
2020

21+
from flask import current_app
2122
from jinja2 import Environment, FileSystemLoader
2223
from sbc_common_components.utils.enums import QueueMessageTypes
2324
from structured_logging import StructuredLogging
@@ -338,6 +339,7 @@ def _add_or_remove_group(model: MembershipModel):
338339

339340
@staticmethod
340341
def add_or_remove_group_for_staff(model: MembershipModel):
342+
"""Add or remove the user from/to various staff keycloak groups."""
341343
mapping_group = org_type_to_group_mapping.get(model.org.type_code)
342344
if not mapping_group:
343345
return
@@ -383,3 +385,11 @@ def add_staff_membership(user_id):
383385
def remove_staff_membership(user_id):
384386
"""Remove staff membership for the specified user."""
385387
MembershipModel.remove_membership_for_staff(user_id)
388+
389+
@staticmethod
390+
def create_admin_membership_for_api_user(org_id, user_id):
391+
"""Create a membership for an api user."""
392+
current_app.logger.info(f"Creating membership in {org_id} for API user {user_id}")
393+
return MembershipModel(
394+
org_id=org_id, user_id=user_id, membership_type_code=ADMIN, status=Status.ACTIVE.value
395+
).save()

auth-api/src/auth_api/services/user.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
"""
1818

1919
import json
20+
from datetime import datetime, timezone
2021
from http import HTTPStatus
2122
from typing import Dict, List
2223

24+
from flask import current_app
2325
from jinja2 import Environment, FileSystemLoader
2426
from requests import HTTPError
2527
from sbc_common_components.utils.enums import QueueMessageTypes
@@ -34,6 +36,7 @@
3436
from auth_api.models import User as UserModel
3537
from auth_api.models import db
3638
from auth_api.models.dataclass import Activity
39+
from auth_api.models.user import UserStatusCode
3740
from auth_api.schemas import UserSchema
3841
from auth_api.services.authorization import check_auth
3942
from auth_api.services.keycloak_user import KeycloakUser

auth-api/src/auth_api/utils/api_gateway.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,13 @@
1616
import uuid
1717

1818

19-
def generate_client_representation(account_id: int, client_id_pattern: str, env: str) -> dict:
19+
def generate_client_representation(account_id: int, client_id: str) -> dict:
2020
"""Return dictionary for api gateway client user."""
2121
_id = str(uuid.uuid4())
2222
_secret = secrets.token_urlsafe(36)
23-
if env != "prod":
24-
client_id_pattern += "-sandbox"
25-
_client_id = client_id_pattern.format(account_id=account_id)
26-
2723
client_json: dict = {
2824
"id": _id,
29-
"clientId": _client_id,
25+
"clientId": client_id,
3026
"rootUrl": "",
3127
"adminUrl": "",
3228
"baseUrl": "",
@@ -86,7 +82,7 @@ def generate_client_representation(account_id: int, client_id_pattern: str, env:
8682
"protocolMapper": "oidc-hardcoded-claim-mapper",
8783
"consentRequired": False,
8884
"config": {
89-
"claim.value": _client_id,
85+
"claim.value": client_id,
9086
"userinfo.token.claim": "true",
9187
"id.token.claim": "true",
9288
"access.token.claim": "true",
@@ -114,7 +110,7 @@ def generate_client_representation(account_id: int, client_id_pattern: str, env:
114110
"protocolMapper": "oidc-hardcoded-claim-mapper",
115111
"consentRequired": False,
116112
"config": {
117-
"claim.value": _client_id,
113+
"claim.value": client_id,
118114
"userinfo.token.claim": "true",
119115
"id.token.claim": "true",
120116
"access.token.claim": "true",
@@ -128,7 +124,7 @@ def generate_client_representation(account_id: int, client_id_pattern: str, env:
128124
"protocolMapper": "oidc-hardcoded-claim-mapper",
129125
"consentRequired": False,
130126
"config": {
131-
"claim.value": _client_id,
127+
"claim.value": client_id,
132128
"userinfo.token.claim": "true",
133129
"id.token.claim": "true",
134130
"access.token.claim": "true",
@@ -142,7 +138,7 @@ def generate_client_representation(account_id: int, client_id_pattern: str, env:
142138
"protocolMapper": "oidc-hardcoded-claim-mapper",
143139
"consentRequired": False,
144140
"config": {
145-
"claim.value": _client_id,
141+
"claim.value": client_id,
146142
"userinfo.token.claim": "true",
147143
"id.token.claim": "true",
148144
"access.token.claim": "true",
@@ -156,7 +152,7 @@ def generate_client_representation(account_id: int, client_id_pattern: str, env:
156152
"protocolMapper": "oidc-hardcoded-claim-mapper",
157153
"consentRequired": False,
158154
"config": {
159-
"claim.value": _client_id,
155+
"claim.value": client_id,
160156
"userinfo.token.claim": "true",
161157
"id.token.claim": "true",
162158
"access.token.claim": "true",
@@ -215,7 +211,7 @@ def generate_client_representation(account_id: int, client_id_pattern: str, env:
215211
"protocolMapper": "oidc-hardcoded-claim-mapper",
216212
"consentRequired": False,
217213
"config": {
218-
"claim.value": _client_id,
214+
"claim.value": client_id,
219215
"userinfo.token.claim": "true",
220216
"id.token.claim": "true",
221217
"access.token.claim": "true",

auth-api/tests/docker/docker-compose.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ version: "3"
22

33
services:
44
keycloak:
5-
image: quay.io/keycloak/keycloak:12.0.2
5+
image: quay.io/keycloak/keycloak:26.0
66
ports:
77
- "8081:8081"
88
environment:
9-
- KEYCLOAK_USER=admin
10-
- KEYCLOAK_PASSWORD=admin
11-
command: -b 0.0.0.0 -Djboss.http.port=8081 -Dkeycloak.migration.action=import -Dkeycloak.migration.provider=dir -Dkeycloak.migration.dir=/tmp/keycloak/test -Dkeycloak.migration.strategy=OVERWRITE_EXISTING
9+
- KEYCLOAK_ADMIN=admin
10+
- KEYCLOAK_ADMIN_PASSWORD=admin
11+
volumes:
12+
- ./setup:/opt/keycloak/data/import
13+
command: ["start-dev", "--import-realm", "--http-port=8081", "--http-relative-path=/auth"]
1214
healthcheck:
1315
test:
1416
[
@@ -20,8 +22,6 @@ services:
2022
interval: 30s
2123
timeout: 10s
2224
retries: 10
23-
volumes:
24-
- ./setup:/tmp/keycloak/test/
2525
nats:
2626
image: nats-streaming
2727
restart: always

auth-api/tests/unit/api/test_org.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
AccessType,
4444
AffidavitStatus,
4545
CorpType,
46+
LoginSource,
4647
NRActionCodes,
4748
NRStatus,
4849
OrgStatus,
@@ -66,6 +67,7 @@
6667
TestJwtClaims,
6768
TestOrgInfo,
6869
TestPaymentMethodInfo,
70+
TestUserInfo,
6971
)
7072
from tests.utilities.factory_utils import (
7173
convert_org_to_staff_org,
@@ -1276,6 +1278,11 @@ def test_get_members(client, jwt, session, keycloak_mock): # pylint:disable=unu
12761278
)
12771279
dictionary = json.loads(rv.data)
12781280
org_id = dictionary["id"]
1281+
# Create API_GW user, this shouldn't show up in the users list
1282+
user_dict = dict(TestUserInfo.user1)
1283+
user_dict["login_source"] = LoginSource.API_GW.value
1284+
api_user = factory_user_model(user_dict)
1285+
factory_membership_model(api_user.id, org_id=org_id)
12791286

12801287
rv = client.get("/api/v1/orgs/{}/members".format(org_id), headers=headers, content_type="application/json")
12811288

@@ -2936,7 +2943,7 @@ def test_update_org_api_access(client, jwt, session, keycloak_mock): # pylint:d
29362943

29372944
def test_search_org_members(client, jwt, session, keycloak_mock): # pylint:disable=unused-argument
29382945
"""Assert that a list of members for an org search can be retrieved."""
2939-
user_info = TestJwtClaims.public_user_role
2946+
user_info = dict(TestJwtClaims.public_user_role)
29402947
headers = factory_auth_header(jwt=jwt, claims=user_info)
29412948
client.post("/api/v1/users", headers=headers, content_type="application/json")
29422949
client.post("/api/v1/orgs", data=json.dumps(TestOrgInfo.org1), headers=headers, content_type="application/json")

auth-api/tests/unit/api/test_org_api_keys.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@
1818
"""
1919

2020
import json
21+
from contextlib import suppress
2122
from http import HTTPStatus
2223

24+
from auth_api.services.api_gateway import ApiGateway
25+
from auth_api.services.keycloak import KeycloakService
26+
from auth_api.utils.api_gateway import generate_client_representation
2327
from tests.utilities.factory_scenarios import TestJwtClaims, TestOrgInfo
2428
from tests.utilities.factory_utils import factory_auth_header
2529

0 commit comments

Comments
 (0)