Skip to content

Commit 39046e6

Browse files
feat(server-utils,auth-server): Allow users to get their own info (#21449)
This closes EXEC-2610. * Split the `users.read` scope into `users.read.others` and `users.read.self`. Regular users get `users.read.self`, and admins get `users.read.others`. * Add a new `GET /users/self` endpoint, requiring `users.read.self`. * Rename the existing endpoints from `/users/{username}` to `/users/byUsername/{username}`. These require `users.read.others`. * Update the auth utilities in `server_utils` to support this use case. This basically means letting endpoints access some lower-level auth information, so they can implement custom authorization logic themselves, instead of wrapping it all up in a FastAPI dependency.
1 parent 039c915 commit 39046e6

13 files changed

Lines changed: 352 additions & 129 deletions

File tree

auth-server/auth_server/users/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,13 @@ class AccountType(StrEnum):
2929
# todo(mm, 2026-03-17): Updates should be togglable to admin-only by an auth setting.
3030
Scope.UPDATES_WRITE,
3131
# todo(mm, 2026-03-17): Protocol uploads should be togglable to admin-only by an auth setting.
32+
Scope.USERS_READ_SELF,
3233
Scope.PROTOCOLS_WRITE,
3334
},
3435
# Auditors should have read-only access to everything. Our read-only endpoints are
3536
# mostly accessible without authentication, but there are some exceptions. This
3637
# just needs to have the scopes to cover those exceptions.
37-
AccountType.AUDITOR: {Scope.USERS_READ},
38+
AccountType.AUDITOR: {Scope.USERS_READ_OTHERS},
3839
}
3940

4041

auth-server/auth_server/users/router.py

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
import fastapi
44

5-
from server_utils.auth.resource_server.fastapi import require_scopes
5+
from server_utils.auth.resource_server.authorization_checker import (
6+
AuthorizationNotRequiredResult,
7+
)
8+
from server_utils.auth.resource_server.fastapi import (
9+
RequireScopesResult,
10+
require_scopes,
11+
)
612
from server_utils.auth.scopes import Scope
713
from server_utils.fastapi_utils.models.json_api import (
814
PydanticResponse,
@@ -11,8 +17,6 @@
1117
SimpleEmptyBody,
1218
)
1319

14-
from auth_server.oauth2.backend import Backend
15-
from auth_server.oauth2.fastapi_dependencies import get_oauth2_backend
1620
from auth_server.users.dependencies import get_user_data_manager
1721
from auth_server.users.models import UpdateUser, UserCreate, UserResponse
1822
from auth_server.users.user_data_manager import (
@@ -36,9 +40,7 @@
3640
dependencies=[fastapi.Depends(require_scopes(Scope.USERS_WRITE))],
3741
)
3842
async def post_users(
39-
request: fastapi.Request,
4043
request_body: RequestModel[UserCreate],
41-
oauth2_backend: Annotated[Backend, fastapi.Depends(get_oauth2_backend)],
4244
user_data_manager: Annotated[
4345
UserDataManager, fastapi.Depends(get_user_data_manager)
4446
],
@@ -72,19 +74,17 @@ async def post_users(
7274

7375
@PydanticResponse.wrap_route(
7476
router.get,
75-
path="/auth/users/{userName}",
76-
summary="Get a user information",
77-
description="Get a specific user by its unique identifier.",
77+
path="/auth/users/byUsername/{userName}",
78+
summary="Get a user",
79+
description="Get a specific user, identified by their unique username.",
7880
responses={
7981
fastapi.status.HTTP_200_OK: {"model": SimpleBody[UserResponse]},
8082
fastapi.status.HTTP_404_NOT_FOUND: {"userNotFound": None},
8183
},
82-
dependencies=[fastapi.Depends(require_scopes(Scope.USERS_READ))],
84+
dependencies=[fastapi.Depends(require_scopes(Scope.USERS_READ_OTHERS))],
8385
)
8486
async def get_user(
85-
request: fastapi.Request,
8687
userName: str,
87-
oauth2_backend: Annotated[Backend, fastapi.Depends(get_oauth2_backend)],
8888
user_data_manager: Annotated[
8989
UserDataManager, fastapi.Depends(get_user_data_manager)
9090
],
@@ -105,18 +105,16 @@ async def get_user(
105105

106106
@PydanticResponse.wrap_route(
107107
router.delete,
108-
path="/auth/users/{userName}",
108+
path="/auth/users/byUsername/{userName}",
109109
summary="Delete a user",
110-
description="Delete a specific user by its unique identifier.",
110+
description="Delete a specific user, identified by their unique username.",
111111
responses={
112112
fastapi.status.HTTP_204_NO_CONTENT: {"description": "User deleted"},
113113
},
114114
dependencies=[fastapi.Depends(require_scopes(Scope.USERS_WRITE))],
115115
)
116116
async def delete_user(
117-
request: fastapi.Request,
118117
userName: str,
119-
oauth2_backend: Annotated[Backend, fastapi.Depends(get_oauth2_backend)],
120118
user_data_manager: Annotated[
121119
UserDataManager, fastapi.Depends(get_user_data_manager)
122120
],
@@ -137,19 +135,17 @@ async def delete_user(
137135

138136
@PydanticResponse.wrap_route(
139137
router.patch,
140-
path="/auth/users/{userName}",
138+
path="/auth/users/byUsername/{userName}",
141139
summary="Update a user",
142-
description="Update a specific user by its unique identifier.",
140+
description="Update a specific user, identified by their unique username.",
143141
responses={
144142
fastapi.status.HTTP_200_OK: {"model": SimpleBody[UserResponse]},
145143
},
146144
dependencies=[fastapi.Depends(require_scopes(Scope.USERS_WRITE))],
147145
)
148146
async def update_user(
149-
request: fastapi.Request,
150147
request_body: RequestModel[UpdateUser],
151148
userName: str,
152-
oauth2_backend: Annotated[Backend, fastapi.Depends(get_oauth2_backend)],
153149
user_data_manager: Annotated[
154150
UserDataManager, fastapi.Depends(get_user_data_manager)
155151
],
@@ -187,3 +183,39 @@ async def update_user(
187183
status_code=fastapi.status.HTTP_200_OK,
188184
content=SimpleBody(data=updated_user),
189185
)
186+
187+
188+
@PydanticResponse.wrap_route(
189+
router.get,
190+
path="/auth/users/self",
191+
summary="Get the currently logged-in user",
192+
description=(
193+
'The "currently logged-in user" is determined from the OAuth 2 access token'
194+
" that you attach to your request to this endpoint."
195+
" See the `/auth/oauth2` endpoints."
196+
),
197+
responses={fastapi.status.HTTP_401_UNAUTHORIZED: {}},
198+
)
199+
async def get_self( # noqa: D103
200+
authorization_details: Annotated[
201+
RequireScopesResult, fastapi.Depends(require_scopes(Scope.USERS_READ_SELF))
202+
],
203+
user_data_manager: Annotated[
204+
UserDataManager, fastapi.Depends(get_user_data_manager)
205+
],
206+
) -> PydanticResponse[SimpleBody[UserResponse]]:
207+
if isinstance(authorization_details, AuthorizationNotRequiredResult):
208+
raise fastapi.HTTPException(
209+
status_code=fastapi.status.HTTP_401_UNAUTHORIZED,
210+
detail="This endpoint needs an access token to determine the current user.",
211+
)
212+
213+
# Note: No try/except for UserNotFoundError. If the user passed `require_scopes()`,
214+
# but we can't find them here, then that's some kind of server bug and we want to
215+
# let it propagate with HTTP error code 500.
216+
user = user_data_manager.get_user(authorization_details.username)
217+
218+
return await PydanticResponse.create(
219+
status_code=fastapi.status.HTTP_200_OK,
220+
content=SimpleBody(data=user),
221+
)

auth-server/tests/integration/test_failed_login_lockout.tavern.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ stages:
120120
- name: Use admin credentials to see the lockout
121121
request:
122122
method: GET
123-
url: '{run_server.base_url}/auth/users/test_user'
123+
url: '{run_server.base_url}/auth/users/byUsername/test_user'
124124
headers:
125125
Authorization: '{admin_access_token}'
126126
response:
@@ -134,7 +134,7 @@ stages:
134134
- name: Use admin credentials to clear the lockout
135135
request:
136136
method: PATCH
137-
url: '{run_server.base_url}/auth/users/test_user'
137+
url: '{run_server.base_url}/auth/users/byUsername/test_user'
138138
headers:
139139
Authorization: '{admin_access_token}'
140140
json:

auth-server/tests/integration/test_protected_resource_access.tavern.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,15 @@ stages:
5252
grant_type: password
5353
username: test_admin
5454
password: test_admin_password
55-
scope: users.read # Omit users.write.
55+
scope: users.read.others # Omit users.write.
5656
response:
5757
status_code: 200
5858
json:
5959
access_token: !anystr
6060
refresh_token: !anystr
6161
expires_in: !anyint
6262
token_type: Bearer
63-
scope: users.read
63+
scope: users.read.others
6464
save:
6565
json:
6666
access_token: access_token

auth-server/tests/integration/test_user_crud_flows.tavern.yaml

Lines changed: 137 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ stages:
5151
- name: Get a user information
5252
request:
5353
method: GET
54-
url: '{run_server.base_url}/auth/users/test_new_user'
54+
url: '{run_server.base_url}/auth/users/byUsername/test_new_user'
5555
headers:
5656
Authorization: '{admin_access_token}'
5757
response:
@@ -68,7 +68,7 @@ stages:
6868
- name: Update the user
6969
request:
7070
method: PATCH
71-
url: '{run_server.base_url}/auth/users/test_new_user'
71+
url: '{run_server.base_url}/auth/users/byUsername/test_new_user'
7272
json:
7373
data:
7474
fullName: Test New User Updated
@@ -89,7 +89,7 @@ stages:
8989
- name: Update the username and password
9090
request:
9191
method: PATCH
92-
url: '{run_server.base_url}/auth/users/test_new_user'
92+
url: '{run_server.base_url}/auth/users/byUsername/test_new_user'
9393
json:
9494
data:
9595
userName: test_new_user_updated
@@ -105,12 +105,12 @@ stages:
105105
userName: test_new_user_updated
106106
locked: false
107107
scopes: !force_original_structure '{admin_scopes_list}'
108-
resetPassword: false
108+
resetPassword: false
109109

110110
- name: Update the reset password flag
111111
request:
112112
method: PATCH
113-
url: '{run_server.base_url}/auth/users/test_new_user_updated'
113+
url: '{run_server.base_url}/auth/users/byUsername/test_new_user_updated'
114114
json:
115115
data:
116116
resetPassword: true
@@ -130,7 +130,7 @@ stages:
130130
- name: Get the user information
131131
request:
132132
method: GET
133-
url: '{run_server.base_url}/auth/users/test_new_user_updated'
133+
url: '{run_server.base_url}/auth/users/byUsername/test_new_user_updated'
134134
headers:
135135
Authorization: '{admin_access_token}'
136136
response:
@@ -163,7 +163,7 @@ stages:
163163
- name: Delete the user
164164
request:
165165
method: DELETE
166-
url: '{run_server.base_url}/auth/users/test_new_user_updated'
166+
url: '{run_server.base_url}/auth/users/byUsername/test_new_user_updated'
167167
headers:
168168
Authorization: '{admin_access_token}'
169169
response:
@@ -172,8 +172,137 @@ stages:
172172
- name: Try to get the deleted user
173173
request:
174174
method: GET
175-
url: '{run_server.base_url}/auth/users/test_new_user_updated'
175+
url: '{run_server.base_url}/auth/users/byUsername/test_new_user_updated'
176176
headers:
177177
Authorization: '{admin_access_token}'
178178
response:
179179
status_code: 404
180+
181+
---
182+
test_name: Letting users read their own info
183+
184+
marks:
185+
- usefixtures:
186+
- run_server
187+
- admin_access_token
188+
189+
stages:
190+
- name: Enable access control mode
191+
request:
192+
method: PATCH
193+
url: '{run_server.base_url}/auth/settings/accessControlEnabled'
194+
headers:
195+
Authorization: '{admin_access_token}'
196+
json:
197+
data:
198+
accessControlEnabled: true
199+
response:
200+
status_code: 200
201+
json:
202+
data:
203+
accessControlEnabled: true
204+
strict:
205+
- json:off
206+
207+
- name: Create regular user 1
208+
request:
209+
method: POST
210+
url: '{run_server.base_url}/auth/users'
211+
json:
212+
data:
213+
userName: regular_user_1
214+
password: securepassword123
215+
fullName: Regular User 1
216+
accountType: user
217+
headers:
218+
Authorization: '{admin_access_token}'
219+
response:
220+
status_code: 201
221+
save:
222+
json:
223+
regular_user_1_data: data
224+
225+
- name: Create regular user 2
226+
request:
227+
method: POST
228+
url: '{run_server.base_url}/auth/users'
229+
json:
230+
data:
231+
userName: regular_user_2
232+
password: securepassword123
233+
fullName: Regular User 2
234+
accountType: user
235+
headers:
236+
Authorization: '{admin_access_token}'
237+
response:
238+
status_code: 201
239+
save:
240+
json:
241+
regular_user_2_data: data
242+
243+
- name: Log in as regular user 1 and get an access token
244+
request:
245+
method: POST
246+
url: '{run_server.base_url}/auth/oauth2/token'
247+
data:
248+
client_id: opentrons_app
249+
grant_type: password
250+
username: regular_user_1
251+
password: securepassword123
252+
response:
253+
status_code: 200
254+
json:
255+
access_token: !anystr
256+
strict:
257+
- json:off
258+
save:
259+
json:
260+
regular_user_1_access_token: access_token
261+
262+
- name: Log in as regular user 2 and get an access token
263+
request:
264+
method: POST
265+
url: '{run_server.base_url}/auth/oauth2/token'
266+
data:
267+
client_id: opentrons_app
268+
grant_type: password
269+
username: regular_user_2
270+
password: securepassword123
271+
response:
272+
status_code: 200
273+
json:
274+
access_token: !anystr
275+
strict:
276+
- json:off
277+
save:
278+
json:
279+
regular_user_2_access_token: access_token
280+
281+
- name: Unauthorized requests should be rejected
282+
request:
283+
method: GET
284+
url: '{run_server.base_url}/auth/users/self'
285+
response:
286+
status_code: 401
287+
288+
- name: Regular user 1 should be able to get their own info
289+
request:
290+
method: GET
291+
url: '{run_server.base_url}/auth/users/self'
292+
headers:
293+
authorization: 'Bearer {regular_user_1_access_token}'
294+
response:
295+
status_code: 200
296+
json:
297+
data: !force_original_structure '{regular_user_1_data}'
298+
299+
- name: Regular user 2 should be able to get their own info
300+
request:
301+
method: GET
302+
url: '{run_server.base_url}/auth/users/self'
303+
headers:
304+
authorization: 'Bearer {regular_user_2_access_token}'
305+
response:
306+
status_code: 200
307+
json:
308+
data: !force_original_structure '{regular_user_2_data}'

server-utils/server_utils/auth/resource_server/auth_server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ class TokenIntrospectionResponse(_StrictBaseModel):
131131

132132
active: bool
133133
scope: str = ""
134+
username: str | None = None
134135

135136

136137
class TokenIntrospectionRequestFormData(typing.TypedDict):

0 commit comments

Comments
 (0)