skypilot-nightly 1.0.0.dev20250604__py3-none-any.whl → 1.0.0.dev20250605__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sky/__init__.py +2 -2
- sky/admin_policy.py +5 -0
- sky/catalog/__init__.py +2 -2
- sky/catalog/common.py +7 -9
- sky/cli.py +11 -9
- sky/client/cli.py +11 -9
- sky/client/sdk.py +30 -12
- sky/dashboard/out/404.html +1 -1
- sky/dashboard/out/_next/static/chunks/614-635a84e87800f99e.js +66 -0
- sky/dashboard/out/_next/static/chunks/{856-f1b1f7f47edde2e8.js → 856-3a32da4b84176f6d.js} +1 -1
- sky/dashboard/out/_next/static/chunks/937.3759f538f11a0953.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/config-1a1eeb949dab8897.js +6 -0
- sky/dashboard/out/_next/static/chunks/pages/users-262aab38b9baaf3a.js +16 -0
- sky/dashboard/out/_next/static/chunks/pages/workspaces-384ea5fa0cea8f28.js +1 -0
- sky/dashboard/out/_next/static/chunks/{webpack-f27c9a32aa3d9c6d.js → webpack-65d465f948974c0d.js} +1 -1
- sky/dashboard/out/_next/static/css/667d941a2888ce6e.css +3 -0
- sky/dashboard/out/_next/static/qjhIe-yC6nHcLKBqpzO1M/_buildManifest.js +1 -0
- sky/dashboard/out/clusters/[cluster]/[job].html +1 -1
- sky/dashboard/out/clusters/[cluster].html +1 -1
- sky/dashboard/out/clusters.html +1 -1
- sky/dashboard/out/config.html +1 -1
- sky/dashboard/out/index.html +1 -1
- sky/dashboard/out/infra/[context].html +1 -1
- sky/dashboard/out/infra.html +1 -1
- sky/dashboard/out/jobs/[job].html +1 -1
- sky/dashboard/out/jobs.html +1 -1
- sky/dashboard/out/users.html +1 -1
- sky/dashboard/out/workspace/new.html +1 -1
- sky/dashboard/out/workspaces/[name].html +1 -1
- sky/dashboard/out/workspaces.html +1 -1
- sky/execution.py +44 -46
- sky/global_user_state.py +118 -83
- sky/jobs/client/sdk.py +4 -1
- sky/jobs/server/core.py +5 -1
- sky/models.py +1 -0
- sky/resources.py +22 -1
- sky/server/constants.py +3 -1
- sky/server/requests/payloads.py +9 -0
- sky/server/server.py +30 -9
- sky/setup_files/MANIFEST.in +1 -0
- sky/setup_files/dependencies.py +2 -0
- sky/skylet/constants.py +10 -4
- sky/skypilot_config.py +4 -2
- sky/templates/websocket_proxy.py +11 -1
- sky/users/__init__.py +0 -0
- sky/users/model.conf +15 -0
- sky/users/permission.py +178 -0
- sky/users/rbac.py +86 -0
- sky/users/server.py +66 -0
- sky/utils/schemas.py +20 -7
- sky/workspaces/core.py +2 -2
- {skypilot_nightly-1.0.0.dev20250604.dist-info → skypilot_nightly-1.0.0.dev20250605.dist-info}/METADATA +3 -1
- {skypilot_nightly-1.0.0.dev20250604.dist-info → skypilot_nightly-1.0.0.dev20250605.dist-info}/RECORD +68 -64
- sky/catalog/constants.py +0 -8
- sky/dashboard/out/_next/static/chunks/614-3d29f98e0634b179.js +0 -66
- sky/dashboard/out/_next/static/chunks/937.f97f83652028e944.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/config-35383adcb0edb5e2.js +0 -6
- sky/dashboard/out/_next/static/chunks/pages/users-07b523ccb19317ad.js +0 -6
- sky/dashboard/out/_next/static/chunks/pages/workspaces-f54921ec9eb20965.js +0 -1
- sky/dashboard/out/_next/static/css/63d3995d8b528eb1.css +0 -3
- sky/dashboard/out/_next/static/vWwfD3jOky5J5jULHp8JT/_buildManifest.js +0 -1
- /sky/dashboard/out/_next/static/chunks/{121-8f55ee3fa6301784.js → 121-865d2bf8a3b84c6a.js} +0 -0
- /sky/dashboard/out/_next/static/chunks/{236-fef38aa6e5639300.js → 236-4c0dc6f63ccc6319.js} +0 -0
- /sky/dashboard/out/_next/static/chunks/{37-947904ccc5687bac.js → 37-beedd583fea84cc8.js} +0 -0
- /sky/dashboard/out/_next/static/chunks/{682-2be9b0f169727f2f.js → 682-6647f0417d5662f0.js} +0 -0
- /sky/dashboard/out/_next/static/chunks/{843-a097338acb89b7d7.js → 843-c296541442d4af88.js} +0 -0
- /sky/dashboard/out/_next/static/chunks/{969-d7b6fb7f602bfcb3.js → 969-c7abda31c10440ac.js} +0 -0
- /sky/dashboard/out/_next/static/chunks/pages/{_app-67925f5e6382e22f.js → _app-cb81dc4d27f4d009.js} +0 -0
- /sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]/{[job]-158b70da336d8607.js → [job]-65d04d5d77cbb6b6.js} +0 -0
- /sky/dashboard/out/_next/static/chunks/pages/clusters/{[cluster]-62c9982dc3675725.js → [cluster]-beabbcd7606c1a23.js} +0 -0
- /sky/dashboard/out/_next/static/chunks/pages/jobs/{[job]-a62a3c65dc9bc57c.js → [job]-86c47edc500f15f9.js} +0 -0
- /sky/dashboard/out/_next/static/{vWwfD3jOky5J5jULHp8JT → qjhIe-yC6nHcLKBqpzO1M}/_ssgManifest.js +0 -0
- {skypilot_nightly-1.0.0.dev20250604.dist-info → skypilot_nightly-1.0.0.dev20250605.dist-info}/WHEEL +0 -0
- {skypilot_nightly-1.0.0.dev20250604.dist-info → skypilot_nightly-1.0.0.dev20250605.dist-info}/entry_points.txt +0 -0
- {skypilot_nightly-1.0.0.dev20250604.dist-info → skypilot_nightly-1.0.0.dev20250605.dist-info}/licenses/LICENSE +0 -0
- {skypilot_nightly-1.0.0.dev20250604.dist-info → skypilot_nightly-1.0.0.dev20250605.dist-info}/top_level.txt +0 -0
sky/server/server.py
CHANGED
@@ -49,6 +49,7 @@ from sky.server.requests import preconditions
|
|
49
49
|
from sky.server.requests import requests as requests_lib
|
50
50
|
from sky.skylet import constants
|
51
51
|
from sky.usage import usage_lib
|
52
|
+
from sky.users import server as users_rest
|
52
53
|
from sky.utils import admin_policy_utils
|
53
54
|
from sky.utils import common as common_lib
|
54
55
|
from sky.utils import common_utils
|
@@ -100,6 +101,27 @@ logger = sky_logging.init_logger(__name__)
|
|
100
101
|
# response will block other requests from being processed.
|
101
102
|
|
102
103
|
|
104
|
+
class RBACMiddleware(starlette.middleware.base.BaseHTTPMiddleware):
|
105
|
+
"""Middleware to handle RBAC."""
|
106
|
+
|
107
|
+
async def dispatch(self, request: fastapi.Request, call_next):
|
108
|
+
if request.url.path.startswith('/dashboard/'):
|
109
|
+
return await call_next(request)
|
110
|
+
|
111
|
+
auth_user = _get_auth_user_header(request)
|
112
|
+
if auth_user is None:
|
113
|
+
return await call_next(request)
|
114
|
+
|
115
|
+
permission_service = users_rest.permission_service
|
116
|
+
# Check the role permission
|
117
|
+
if permission_service.check_permission(auth_user.id, request.url.path,
|
118
|
+
request.method):
|
119
|
+
return fastapi.responses.JSONResponse(
|
120
|
+
status_code=403, content={'detail': 'Forbidden'})
|
121
|
+
|
122
|
+
return await call_next(request)
|
123
|
+
|
124
|
+
|
103
125
|
class RequestIDMiddleware(starlette.middleware.base.BaseHTTPMiddleware):
|
104
126
|
"""Middleware to add a request ID to each request."""
|
105
127
|
|
@@ -130,7 +152,10 @@ class AuthProxyMiddleware(starlette.middleware.base.BaseHTTPMiddleware):
|
|
130
152
|
|
131
153
|
# Add user to database if auth_user is present
|
132
154
|
if auth_user is not None:
|
133
|
-
global_user_state.add_or_update_user(auth_user)
|
155
|
+
newly_added = global_user_state.add_or_update_user(auth_user)
|
156
|
+
if newly_added:
|
157
|
+
users_rest.permission_service.add_user_if_not_exists(
|
158
|
+
auth_user.id)
|
134
159
|
|
135
160
|
body = await request.body()
|
136
161
|
if auth_user and body:
|
@@ -244,11 +269,13 @@ class PathCleanMiddleware(starlette.middleware.base.BaseHTTPMiddleware):
|
|
244
269
|
parent = pathlib.Path('/dashboard')
|
245
270
|
request_path = pathlib.Path(posixpath.normpath(request.url.path))
|
246
271
|
if not _is_relative_to(request_path, parent):
|
247
|
-
|
272
|
+
return fastapi.responses.JSONResponse(
|
273
|
+
status_code=403, content={'detail': 'Forbidden'})
|
248
274
|
return await call_next(request)
|
249
275
|
|
250
276
|
|
251
277
|
app = fastapi.FastAPI(prefix='/api/v1', debug=True, lifespan=lifespan)
|
278
|
+
app.add_middleware(RBACMiddleware)
|
252
279
|
app.add_middleware(InternalDashboardPrefixMiddleware)
|
253
280
|
app.add_middleware(PathCleanMiddleware)
|
254
281
|
app.add_middleware(CacheControlStaticMiddleware)
|
@@ -266,6 +293,7 @@ app.add_middleware(AuthProxyMiddleware)
|
|
266
293
|
app.add_middleware(RequestIDMiddleware)
|
267
294
|
app.include_router(jobs_rest.router, prefix='/jobs', tags=['jobs'])
|
268
295
|
app.include_router(serve_rest.router, prefix='/serve', tags=['serve'])
|
296
|
+
app.include_router(users_rest.router, prefix='/users', tags=['users'])
|
269
297
|
app.include_router(workspaces_rest.router,
|
270
298
|
prefix='/workspaces',
|
271
299
|
tags=['workspaces'])
|
@@ -835,13 +863,6 @@ async def logs(
|
|
835
863
|
)
|
836
864
|
|
837
865
|
|
838
|
-
@app.get('/users')
|
839
|
-
async def users() -> List[Dict[str, Any]]:
|
840
|
-
"""Gets all users."""
|
841
|
-
user_list = global_user_state.get_all_users()
|
842
|
-
return [user.to_dict() for user in user_list]
|
843
|
-
|
844
|
-
|
845
866
|
@app.post('/download_logs')
|
846
867
|
async def download_logs(
|
847
868
|
request: fastapi.Request,
|
sky/setup_files/MANIFEST.in
CHANGED
sky/setup_files/dependencies.py
CHANGED
sky/skylet/constants.py
CHANGED
@@ -379,7 +379,7 @@ OVERRIDEABLE_CONFIG_KEYS_IN_TASK: List[Tuple[str, ...]] = [
|
|
379
379
|
SKIPPED_CLIENT_OVERRIDE_KEYS: List[Tuple[str, ...]] = [('admin_policy',),
|
380
380
|
('api_server',),
|
381
381
|
('allowed_clouds',),
|
382
|
-
('workspaces',)]
|
382
|
+
('workspaces',), ('db',)]
|
383
383
|
|
384
384
|
# Constants for Azure blob storage
|
385
385
|
WAIT_FOR_STORAGE_ACCOUNT_CREATION = 60
|
@@ -409,6 +409,12 @@ ENV_VAR_IS_SKYPILOT_SERVER = 'IS_SKYPILOT_SERVER'
|
|
409
409
|
|
410
410
|
SKYPILOT_DEFAULT_WORKSPACE = 'default'
|
411
411
|
|
412
|
-
#
|
413
|
-
|
414
|
-
|
412
|
+
# BEGIN constants used for service catalog.
|
413
|
+
HOSTED_CATALOG_DIR_URL = 'https://raw.githubusercontent.com/skypilot-org/skypilot-catalog/master/catalogs' # pylint: disable=line-too-long
|
414
|
+
HOSTED_CATALOG_DIR_URL_S3_MIRROR = 'https://skypilot-catalog.s3.us-east-1.amazonaws.com/catalogs' # pylint: disable=line-too-long
|
415
|
+
CATALOG_SCHEMA_VERSION = 'v7'
|
416
|
+
CATALOG_DIR = '~/.sky/catalogs'
|
417
|
+
ALL_CLOUDS = ('aws', 'azure', 'gcp', 'ibm', 'lambda', 'scp', 'oci',
|
418
|
+
'kubernetes', 'runpod', 'vast', 'vsphere', 'cudo', 'fluidstack',
|
419
|
+
'paperspace', 'do', 'nebius', 'ssh')
|
420
|
+
# END constants used for service catalog.
|
sky/skypilot_config.py
CHANGED
@@ -756,13 +756,15 @@ def apply_cli_config(cli_config: Optional[List[str]]) -> Dict[str, Any]:
|
|
756
756
|
return parsed_config
|
757
757
|
|
758
758
|
|
759
|
-
def
|
759
|
+
def update_api_server_config_no_lock(config: config_utils.Config) -> None:
|
760
760
|
"""Dumps the new config to a file and syncs to ConfigMap if in Kubernetes.
|
761
761
|
|
762
762
|
Args:
|
763
763
|
config: The config to save and sync.
|
764
764
|
"""
|
765
|
-
global_config_path =
|
765
|
+
global_config_path = _resolve_server_config_path()
|
766
|
+
if global_config_path is None:
|
767
|
+
global_config_path = get_user_config_path()
|
766
768
|
|
767
769
|
# Always save to the local file (PVC in Kubernetes, local file otherwise)
|
768
770
|
common_utils.dump_yaml(global_config_path, dict(config))
|
sky/templates/websocket_proxy.py
CHANGED
@@ -21,11 +21,21 @@ from websockets.asyncio.client import connect
|
|
21
21
|
|
22
22
|
BUFFER_SIZE = 2**16 # 64KB
|
23
23
|
|
24
|
+
# Environment variable for a file path to the API cookie file.
|
25
|
+
# Keep in sync with server/constants.py
|
26
|
+
API_COOKIE_FILE_ENV_VAR = 'SKYPILOT_API_COOKIE_FILE'
|
27
|
+
# Default file if unset.
|
28
|
+
# Keep in sync with server/constants.py
|
29
|
+
API_COOKIE_FILE_DEFAULT_LOCATION = '~/.sky/cookies.txt'
|
30
|
+
|
24
31
|
|
25
32
|
def _get_cookie_header(url: str) -> Dict[str, str]:
|
26
33
|
"""Extract Cookie header value from a cookie jar for a specific URL"""
|
27
|
-
cookie_path = os.environ.get(
|
34
|
+
cookie_path = os.environ.get(API_COOKIE_FILE_ENV_VAR)
|
28
35
|
if cookie_path is None:
|
36
|
+
cookie_path = API_COOKIE_FILE_DEFAULT_LOCATION
|
37
|
+
cookie_path = os.path.expanduser(cookie_path)
|
38
|
+
if not os.path.exists(cookie_path):
|
29
39
|
return {}
|
30
40
|
|
31
41
|
request = Request(url)
|
sky/users/__init__.py
ADDED
File without changes
|
sky/users/model.conf
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# rbac_model.conf
|
2
|
+
[request_definition]
|
3
|
+
r = sub, obj, act
|
4
|
+
|
5
|
+
[policy_definition]
|
6
|
+
p = sub, obj, act
|
7
|
+
|
8
|
+
[role_definition]
|
9
|
+
g = _, _
|
10
|
+
|
11
|
+
[policy_effect]
|
12
|
+
e = some(where (p.eft == allow))
|
13
|
+
|
14
|
+
[matchers]
|
15
|
+
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
|
sky/users/permission.py
ADDED
@@ -0,0 +1,178 @@
|
|
1
|
+
"""Permission service for SkyPilot API Server."""
|
2
|
+
import contextlib
|
3
|
+
import logging
|
4
|
+
import os
|
5
|
+
import threading
|
6
|
+
from typing import List
|
7
|
+
|
8
|
+
import casbin
|
9
|
+
import filelock
|
10
|
+
import sqlalchemy_adapter
|
11
|
+
|
12
|
+
from sky import global_user_state
|
13
|
+
from sky import sky_logging
|
14
|
+
from sky.users import rbac
|
15
|
+
|
16
|
+
logger = sky_logging.init_logger(__name__)
|
17
|
+
|
18
|
+
# Filelocks for the policy update.
|
19
|
+
POLICY_UPDATE_LOCK_PATH = os.path.expanduser('~/.sky/.policy_update.lock')
|
20
|
+
POLICY_UPDATE_LOCK_TIMEOUT_SECONDS = 20
|
21
|
+
|
22
|
+
_enforcer_instance = None
|
23
|
+
_lock = threading.Lock()
|
24
|
+
|
25
|
+
|
26
|
+
class PermissionService:
|
27
|
+
"""Permission service for SkyPilot API Server."""
|
28
|
+
|
29
|
+
def __init__(self):
|
30
|
+
global _enforcer_instance
|
31
|
+
if _enforcer_instance is None:
|
32
|
+
# For different threads, we share the same enforcer instance.
|
33
|
+
with _lock:
|
34
|
+
if _enforcer_instance is None:
|
35
|
+
_enforcer_instance = self
|
36
|
+
engine = global_user_state.SQLALCHEMY_ENGINE
|
37
|
+
adapter = sqlalchemy_adapter.Adapter(engine)
|
38
|
+
model_path = os.path.join(os.path.dirname(__file__),
|
39
|
+
'model.conf')
|
40
|
+
enforcer = casbin.Enforcer(model_path, adapter)
|
41
|
+
logging.getLogger('casbin.policy').setLevel(
|
42
|
+
sky_logging.ERROR)
|
43
|
+
logging.getLogger('casbin.role').setLevel(sky_logging.ERROR)
|
44
|
+
self.enforcer = enforcer
|
45
|
+
else:
|
46
|
+
self.enforcer = _enforcer_instance.enforcer
|
47
|
+
self._maybe_initialize_policies()
|
48
|
+
|
49
|
+
def _maybe_initialize_policies(self):
|
50
|
+
"""Initialize policies if they don't already exist."""
|
51
|
+
logger.debug(f'Initializing policies in process: {os.getpid()}')
|
52
|
+
|
53
|
+
# Check if policies are already initialized by looking for existing
|
54
|
+
# permission policies in the enforcer
|
55
|
+
existing_policies = self.enforcer.get_policy()
|
56
|
+
|
57
|
+
# If we already have policies for the expected roles, skip
|
58
|
+
# initialization
|
59
|
+
role_permissions = rbac.get_role_permissions()
|
60
|
+
expected_policies = []
|
61
|
+
for role, permissions in role_permissions.items():
|
62
|
+
if permissions['permissions'] and 'blocklist' in permissions[
|
63
|
+
'permissions']:
|
64
|
+
blocklist = permissions['permissions']['blocklist']
|
65
|
+
for item in blocklist:
|
66
|
+
expected_policies.append(
|
67
|
+
[role, item['path'], item['method']])
|
68
|
+
|
69
|
+
# Check if all expected policies already exist
|
70
|
+
policies_exist = all(
|
71
|
+
any(policy == expected
|
72
|
+
for policy in existing_policies)
|
73
|
+
for expected in expected_policies)
|
74
|
+
|
75
|
+
if not policies_exist:
|
76
|
+
# Only clear and reinitialize if policies don't exist or are
|
77
|
+
# incomplete
|
78
|
+
logger.debug('Policies not found or incomplete, initializing...')
|
79
|
+
# Only clear p policies (permission policies),
|
80
|
+
# keep g policies (role policies)
|
81
|
+
self.enforcer.remove_filtered_policy(0)
|
82
|
+
for role, permissions in role_permissions.items():
|
83
|
+
if permissions['permissions'] and 'blocklist' in permissions[
|
84
|
+
'permissions']:
|
85
|
+
blocklist = permissions['permissions']['blocklist']
|
86
|
+
for item in blocklist:
|
87
|
+
path = item['path']
|
88
|
+
method = item['method']
|
89
|
+
self.enforcer.add_policy(role, path, method)
|
90
|
+
self.enforcer.save_policy()
|
91
|
+
else:
|
92
|
+
logger.debug('Policies already exist, skipping initialization')
|
93
|
+
|
94
|
+
# Always ensure users have default roles (this is idempotent)
|
95
|
+
all_users = global_user_state.get_all_users()
|
96
|
+
for user in all_users:
|
97
|
+
self.add_user_if_not_exists(user.id)
|
98
|
+
|
99
|
+
def add_user_if_not_exists(self, user: str) -> None:
|
100
|
+
"""Add user role relationship."""
|
101
|
+
with _policy_lock():
|
102
|
+
user_roles = self.enforcer.get_roles_for_user(user)
|
103
|
+
if not user_roles:
|
104
|
+
logger.info(f'User {user} has no roles, adding'
|
105
|
+
f' default role {rbac.get_default_role()}')
|
106
|
+
self.enforcer.add_grouping_policy(user, rbac.get_default_role())
|
107
|
+
self.enforcer.save_policy()
|
108
|
+
|
109
|
+
def update_role(self, user: str, new_role: str):
|
110
|
+
"""Update user role relationship."""
|
111
|
+
with _policy_lock():
|
112
|
+
# Get current roles
|
113
|
+
self._load_policy_no_lock()
|
114
|
+
# Avoid calling get_user_roles, as it will require the lock.
|
115
|
+
current_roles = self.enforcer.get_roles_for_user(user)
|
116
|
+
if not current_roles:
|
117
|
+
logger.warning(f'User {user} has no roles')
|
118
|
+
else:
|
119
|
+
# TODO(hailong): how to handle multiple roles?
|
120
|
+
current_role = current_roles[0]
|
121
|
+
if current_role == new_role:
|
122
|
+
logger.info(f'User {user} already has role {new_role}')
|
123
|
+
return
|
124
|
+
self.enforcer.remove_grouping_policy(user, current_role)
|
125
|
+
|
126
|
+
# Update user role
|
127
|
+
self.enforcer.add_grouping_policy(user, new_role)
|
128
|
+
self.enforcer.save_policy()
|
129
|
+
|
130
|
+
def get_user_roles(self, user: str) -> List[str]:
|
131
|
+
"""Get all roles for a user.
|
132
|
+
|
133
|
+
This method returns all roles that the user has, including inherited
|
134
|
+
roles. For example, if a user has role 'admin' and 'admin' inherits
|
135
|
+
from 'user', this method will return ['admin', 'user'].
|
136
|
+
|
137
|
+
Args:
|
138
|
+
user: The user ID to get roles for.
|
139
|
+
|
140
|
+
Returns:
|
141
|
+
A list of role names that the user has.
|
142
|
+
"""
|
143
|
+
self._load_policy()
|
144
|
+
return self.enforcer.get_roles_for_user(user)
|
145
|
+
|
146
|
+
def check_permission(self, user: str, path: str, method: str) -> bool:
|
147
|
+
"""Check permission."""
|
148
|
+
# We intentionally don't load the policy here, as it is a hot path, and
|
149
|
+
# we don't support updating the policy.
|
150
|
+
# We don't hold the lock for checking permission, as it is read only and
|
151
|
+
# it is a hot path in every request. It is ok to have a stale policy,
|
152
|
+
# as long as it is eventually consistent.
|
153
|
+
# self._load_policy_no_lock()
|
154
|
+
return self.enforcer.enforce(user, path, method)
|
155
|
+
|
156
|
+
def _load_policy_no_lock(self):
|
157
|
+
"""Load policy from storage."""
|
158
|
+
self.enforcer.load_policy()
|
159
|
+
|
160
|
+
def _load_policy(self):
|
161
|
+
"""Load policy from storage with lock."""
|
162
|
+
with _policy_lock():
|
163
|
+
self._load_policy_no_lock()
|
164
|
+
|
165
|
+
|
166
|
+
@contextlib.contextmanager
|
167
|
+
def _policy_lock():
|
168
|
+
"""Context manager for policy update lock."""
|
169
|
+
try:
|
170
|
+
with filelock.FileLock(POLICY_UPDATE_LOCK_PATH,
|
171
|
+
POLICY_UPDATE_LOCK_TIMEOUT_SECONDS):
|
172
|
+
yield
|
173
|
+
except filelock.Timeout as e:
|
174
|
+
raise RuntimeError(f'Failed to load policy due to a timeout '
|
175
|
+
f'when trying to acquire the lock at '
|
176
|
+
f'{POLICY_UPDATE_LOCK_PATH}. '
|
177
|
+
'Please try again or manually remove the lock '
|
178
|
+
f'file if you believe it is stale.') from e
|
sky/users/rbac.py
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
"""RBAC (Role-Based Access Control) functionality for SkyPilot API Server."""
|
2
|
+
|
3
|
+
import enum
|
4
|
+
from typing import Dict, List
|
5
|
+
|
6
|
+
from sky import sky_logging
|
7
|
+
from sky import skypilot_config
|
8
|
+
|
9
|
+
logger = sky_logging.init_logger(__name__)
|
10
|
+
|
11
|
+
# Default user blocklist for user role
|
12
|
+
# Cannot access workspace CUD operations
|
13
|
+
_DEFAULT_USER_BLOCKLIST = [{
|
14
|
+
'path': '/workspaces/config',
|
15
|
+
'method': 'POST'
|
16
|
+
}, {
|
17
|
+
'path': '/workspaces/update',
|
18
|
+
'method': 'POST'
|
19
|
+
}, {
|
20
|
+
'path': '/workspaces/create',
|
21
|
+
'method': 'POST'
|
22
|
+
}, {
|
23
|
+
'path': '/workspaces/delete',
|
24
|
+
'method': 'POST'
|
25
|
+
}, {
|
26
|
+
'path': '/users/update',
|
27
|
+
'method': 'POST'
|
28
|
+
}]
|
29
|
+
|
30
|
+
|
31
|
+
# Define roles
|
32
|
+
class RoleName(str, enum.Enum):
|
33
|
+
ADMIN = 'admin'
|
34
|
+
USER = 'user'
|
35
|
+
|
36
|
+
|
37
|
+
def get_supported_roles() -> List[str]:
|
38
|
+
return [role_name.value for role_name in RoleName]
|
39
|
+
|
40
|
+
|
41
|
+
def get_default_role() -> str:
|
42
|
+
return skypilot_config.get_nested(('rbac', 'default_role'),
|
43
|
+
default_value=RoleName.ADMIN.value)
|
44
|
+
|
45
|
+
|
46
|
+
def get_role_permissions(
|
47
|
+
) -> Dict[str, Dict[str, Dict[str, List[Dict[str, str]]]]]:
|
48
|
+
"""Get all role permissions from config.
|
49
|
+
|
50
|
+
Returns:
|
51
|
+
Dictionary containing all roles and their permissions configuration.
|
52
|
+
Example:
|
53
|
+
{
|
54
|
+
'admin': {
|
55
|
+
'permissions': {
|
56
|
+
'blocklist': []
|
57
|
+
}
|
58
|
+
},
|
59
|
+
'user': {
|
60
|
+
'permissions': {
|
61
|
+
'blocklist': [
|
62
|
+
{'path': '/workspaces/config', 'method': 'POST'},
|
63
|
+
{'path': '/workspaces/update', 'method': 'POST'}
|
64
|
+
]
|
65
|
+
}
|
66
|
+
}
|
67
|
+
}
|
68
|
+
"""
|
69
|
+
# Get all roles from the config
|
70
|
+
config_permissions = skypilot_config.get_nested(('rbac', 'roles'),
|
71
|
+
default_value={})
|
72
|
+
supported_roles = get_supported_roles()
|
73
|
+
for role, permissions in config_permissions.items():
|
74
|
+
role_name = role.lower()
|
75
|
+
if role_name not in supported_roles:
|
76
|
+
logger.warning(f'Invalid role: {role_name}')
|
77
|
+
continue
|
78
|
+
config_permissions[role_name] = permissions
|
79
|
+
# Add default roles if not present
|
80
|
+
if 'user' not in config_permissions:
|
81
|
+
config_permissions['user'] = {
|
82
|
+
'permissions': {
|
83
|
+
'blocklist': _DEFAULT_USER_BLOCKLIST
|
84
|
+
}
|
85
|
+
}
|
86
|
+
return config_permissions
|
sky/users/server.py
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
"""REST API for workspace management."""
|
2
|
+
|
3
|
+
import hashlib
|
4
|
+
from typing import Any, Dict, List
|
5
|
+
|
6
|
+
import fastapi
|
7
|
+
|
8
|
+
from sky import global_user_state
|
9
|
+
from sky import sky_logging
|
10
|
+
from sky.server.requests import payloads
|
11
|
+
from sky.users import permission
|
12
|
+
from sky.users import rbac
|
13
|
+
from sky.utils import common_utils
|
14
|
+
|
15
|
+
logger = sky_logging.init_logger(__name__)
|
16
|
+
|
17
|
+
router = fastapi.APIRouter()
|
18
|
+
|
19
|
+
permission_service = permission.PermissionService()
|
20
|
+
|
21
|
+
|
22
|
+
@router.get('')
|
23
|
+
async def users() -> List[Dict[str, Any]]:
|
24
|
+
"""Gets all users."""
|
25
|
+
all_users = []
|
26
|
+
user_list = global_user_state.get_all_users()
|
27
|
+
for user in user_list:
|
28
|
+
user_roles = permission_service.get_user_roles(user.id)
|
29
|
+
all_users.append({
|
30
|
+
'id': user.id,
|
31
|
+
'name': user.name,
|
32
|
+
'role': user_roles[0] if user_roles else ''
|
33
|
+
})
|
34
|
+
return all_users
|
35
|
+
|
36
|
+
|
37
|
+
@router.get('/role')
|
38
|
+
async def get_current_user_role(request: fastapi.Request):
|
39
|
+
"""Get current user's role."""
|
40
|
+
# TODO(hailong): is there a reliable way to get the user
|
41
|
+
# hash for the request without 'X-Auth-Request-Email' header?
|
42
|
+
if 'X-Auth-Request-Email' not in request.headers:
|
43
|
+
return {'name': '', 'role': rbac.RoleName.ADMIN.value}
|
44
|
+
user_name = request.headers['X-Auth-Request-Email']
|
45
|
+
user_hash = hashlib.md5(
|
46
|
+
user_name.encode()).hexdigest()[:common_utils.USER_HASH_LENGTH]
|
47
|
+
user_roles = permission_service.get_user_roles(user_hash)
|
48
|
+
return {'name': user_name, 'role': user_roles[0] if user_roles else ''}
|
49
|
+
|
50
|
+
|
51
|
+
@router.post('/update')
|
52
|
+
async def user_update(user_update_body: payloads.UserUpdateBody) -> None:
|
53
|
+
"""Updates the user role."""
|
54
|
+
user_id = user_update_body.user_id
|
55
|
+
role = user_update_body.role
|
56
|
+
supported_roles = rbac.get_supported_roles()
|
57
|
+
if role not in supported_roles:
|
58
|
+
raise fastapi.HTTPException(status_code=400,
|
59
|
+
detail=f'Invalid role: {role}')
|
60
|
+
user_info = global_user_state.get_user(user_id)
|
61
|
+
if not user_info.name:
|
62
|
+
raise fastapi.HTTPException(status_code=400,
|
63
|
+
detail=f'User {user_id} does not exist')
|
64
|
+
|
65
|
+
# Update user role in casbin policy
|
66
|
+
permission_service.update_role(user_id, role)
|
sky/utils/schemas.py
CHANGED
@@ -6,7 +6,6 @@ https://json-schema.org/
|
|
6
6
|
import enum
|
7
7
|
from typing import Any, Dict, List, Tuple
|
8
8
|
|
9
|
-
from sky.catalog import constants as service_catalog_constants
|
10
9
|
from sky.skylet import constants
|
11
10
|
|
12
11
|
|
@@ -70,7 +69,7 @@ def _get_single_resources_schema():
|
|
70
69
|
# Building the regex pattern for the infra field
|
71
70
|
# Format: cloud[/region[/zone]] or wildcards or kubernetes context
|
72
71
|
# Match any cloud name (case insensitive)
|
73
|
-
all_clouds = list(
|
72
|
+
all_clouds = list(constants.ALL_CLOUDS)
|
74
73
|
all_clouds.remove('kubernetes')
|
75
74
|
cloud_pattern = f'(?i:({"|".join(all_clouds)}))'
|
76
75
|
|
@@ -107,8 +106,7 @@ def _get_single_resources_schema():
|
|
107
106
|
'properties': {
|
108
107
|
'cloud': {
|
109
108
|
'type': 'string',
|
110
|
-
'case_insensitive_enum': list(
|
111
|
-
service_catalog_constants.ALL_CLOUDS)
|
109
|
+
'case_insensitive_enum': list(constants.ALL_CLOUDS)
|
112
110
|
},
|
113
111
|
'region': {
|
114
112
|
'type': 'string',
|
@@ -1162,7 +1160,7 @@ def get_config_schema():
|
|
1162
1160
|
'items': {
|
1163
1161
|
'type': 'string',
|
1164
1162
|
'case_insensitive_enum':
|
1165
|
-
(list(
|
1163
|
+
(list(constants.ALL_CLOUDS) + ['cloudflare'])
|
1166
1164
|
}
|
1167
1165
|
}
|
1168
1166
|
|
@@ -1207,10 +1205,21 @@ def get_config_schema():
|
|
1207
1205
|
}
|
1208
1206
|
}
|
1209
1207
|
|
1208
|
+
rbac_schema = {
|
1209
|
+
'type': 'object',
|
1210
|
+
'required': [],
|
1211
|
+
'additionalProperties': False,
|
1212
|
+
'properties': {
|
1213
|
+
'default_role': {
|
1214
|
+
'type': 'string',
|
1215
|
+
'case_insensitive_enum': ['admin', 'user']
|
1216
|
+
},
|
1217
|
+
},
|
1218
|
+
}
|
1219
|
+
|
1210
1220
|
workspace_schema = {'type': 'string'}
|
1211
1221
|
|
1212
|
-
allowed_workspace_cloud_names = list(
|
1213
|
-
service_catalog_constants.ALL_CLOUDS) + ['cloudflare']
|
1222
|
+
allowed_workspace_cloud_names = list(constants.ALL_CLOUDS) + ['cloudflare']
|
1214
1223
|
# Create pattern for not supported clouds, i.e.
|
1215
1224
|
# all clouds except gcp, kubernetes, ssh
|
1216
1225
|
not_supported_clouds = [
|
@@ -1334,6 +1343,9 @@ def get_config_schema():
|
|
1334
1343
|
'workspace': {
|
1335
1344
|
'type': 'string',
|
1336
1345
|
},
|
1346
|
+
'db': {
|
1347
|
+
'type': 'string',
|
1348
|
+
},
|
1337
1349
|
'jobs': controller_resources_schema,
|
1338
1350
|
'serve': controller_resources_schema,
|
1339
1351
|
'allowed_clouds': allowed_clouds,
|
@@ -1344,6 +1356,7 @@ def get_config_schema():
|
|
1344
1356
|
'active_workspace': workspace_schema,
|
1345
1357
|
'workspaces': workspaces_schema,
|
1346
1358
|
'provision': provision_configs,
|
1359
|
+
'rbac': rbac_schema,
|
1347
1360
|
**cloud_configs,
|
1348
1361
|
},
|
1349
1362
|
}
|
sky/workspaces/core.py
CHANGED
@@ -66,7 +66,7 @@ def _update_workspaces_config(
|
|
66
66
|
current_config['workspaces'] = current_workspaces
|
67
67
|
|
68
68
|
# Write the configuration back to the file
|
69
|
-
skypilot_config.
|
69
|
+
skypilot_config.update_api_server_config_no_lock(current_config)
|
70
70
|
|
71
71
|
return current_workspaces
|
72
72
|
except filelock.Timeout as e:
|
@@ -411,7 +411,7 @@ def update_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
411
411
|
_WORKSPACE_CONFIG_LOCK_TIMEOUT_SECONDS):
|
412
412
|
# Convert to config_utils.Config and save
|
413
413
|
config_obj = config_utils.Config.from_dict(config)
|
414
|
-
skypilot_config.
|
414
|
+
skypilot_config.update_api_server_config_no_lock(config_obj)
|
415
415
|
except filelock.Timeout as e:
|
416
416
|
raise RuntimeError(
|
417
417
|
f'Failed to update configuration due to a timeout '
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: skypilot-nightly
|
3
|
-
Version: 1.0.0.
|
3
|
+
Version: 1.0.0.dev20250605
|
4
4
|
Summary: SkyPilot: Run AI on Any Infra — Unified, Faster, Cheaper.
|
5
5
|
Author: SkyPilot Team
|
6
6
|
License: Apache 2.0
|
@@ -49,6 +49,8 @@ Requires-Dist: httpx
|
|
49
49
|
Requires-Dist: setproctitle
|
50
50
|
Requires-Dist: sqlalchemy
|
51
51
|
Requires-Dist: psycopg2-binary
|
52
|
+
Requires-Dist: casbin
|
53
|
+
Requires-Dist: sqlalchemy_adapter
|
52
54
|
Provides-Extra: aws
|
53
55
|
Requires-Dist: awscli>=1.27.10; extra == "aws"
|
54
56
|
Requires-Dist: botocore>=1.29.10; extra == "aws"
|