mirror of
https://github.com/microsoft/onefuzz.git
synced 2025-06-16 03:48:09 +00:00
instance wide configuration (#1010)
TODO: * [x] add setting initial set of admins during deployment
This commit is contained in:
@ -25,6 +25,7 @@ Each event will be submitted via HTTP POST to the user provided URL.
|
|||||||
|
|
||||||
* [crash_reported](#crash_reported)
|
* [crash_reported](#crash_reported)
|
||||||
* [file_added](#file_added)
|
* [file_added](#file_added)
|
||||||
|
* [instance_config_updated](#instance_config_updated)
|
||||||
* [job_created](#job_created)
|
* [job_created](#job_created)
|
||||||
* [job_stopped](#job_stopped)
|
* [job_stopped](#job_stopped)
|
||||||
* [node_created](#node_created)
|
* [node_created](#node_created)
|
||||||
@ -613,6 +614,59 @@ Each event will be submitted via HTTP POST to the user provided URL.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### instance_config_updated
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"admins": [
|
||||||
|
"00000000-0000-0000-0000-000000000000"
|
||||||
|
],
|
||||||
|
"allow_pool_management": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"definitions": {
|
||||||
|
"InstanceConfig": {
|
||||||
|
"properties": {
|
||||||
|
"admins": {
|
||||||
|
"items": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": "Admins",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"allow_pool_management": {
|
||||||
|
"default": true,
|
||||||
|
"title": "Allow Pool Management",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "InstanceConfig",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"$ref": "#/definitions/InstanceConfig"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"config"
|
||||||
|
],
|
||||||
|
"title": "EventInstanceConfigUpdated",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### job_created
|
### job_created
|
||||||
|
|
||||||
#### Example
|
#### Example
|
||||||
@ -4792,6 +4846,18 @@ Each event will be submitted via HTTP POST to the user provided URL.
|
|||||||
"title": "EventFileAdded",
|
"title": "EventFileAdded",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"EventInstanceConfigUpdated": {
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"$ref": "#/definitions/InstanceConfig"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"config"
|
||||||
|
],
|
||||||
|
"title": "EventInstanceConfigUpdated",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"EventJobCreated": {
|
"EventJobCreated": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"config": {
|
"config": {
|
||||||
@ -5376,10 +5442,30 @@ Each event will be submitted via HTTP POST to the user provided URL.
|
|||||||
"regression_reported",
|
"regression_reported",
|
||||||
"file_added",
|
"file_added",
|
||||||
"task_heartbeat",
|
"task_heartbeat",
|
||||||
"node_heartbeat"
|
"node_heartbeat",
|
||||||
|
"instance_config_updated"
|
||||||
],
|
],
|
||||||
"title": "EventType"
|
"title": "EventType"
|
||||||
},
|
},
|
||||||
|
"InstanceConfig": {
|
||||||
|
"properties": {
|
||||||
|
"admins": {
|
||||||
|
"items": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": "Admins",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"allow_pool_management": {
|
||||||
|
"default": true,
|
||||||
|
"title": "Allow Pool Management",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "InstanceConfig",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"JobConfig": {
|
"JobConfig": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"build": {
|
"build": {
|
||||||
@ -6071,6 +6157,9 @@ Each event will be submitted via HTTP POST to the user provided URL.
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/definitions/EventFileAdded"
|
"$ref": "#/definitions/EventFileAdded"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/EventInstanceConfigUpdated"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Event"
|
"title": "Event"
|
||||||
|
48
src/api-service/__app__/instance_config/__init__.py
Normal file
48
src/api-service/__app__/instance_config/__init__.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# Copyright (c) Microsoft Corporation.
|
||||||
|
# Licensed under the MIT License.
|
||||||
|
|
||||||
|
import azure.functions as func
|
||||||
|
from onefuzztypes.enums import ErrorCode
|
||||||
|
from onefuzztypes.models import Error
|
||||||
|
from onefuzztypes.requests import InstanceConfigUpdate
|
||||||
|
|
||||||
|
from ..onefuzzlib.config import InstanceConfig
|
||||||
|
from ..onefuzzlib.endpoint_authorization import call_if_user, can_modify_config
|
||||||
|
from ..onefuzzlib.events import get_events
|
||||||
|
from ..onefuzzlib.request import not_ok, ok, parse_request
|
||||||
|
|
||||||
|
|
||||||
|
def get(req: func.HttpRequest) -> func.HttpResponse:
|
||||||
|
return ok(InstanceConfig.fetch())
|
||||||
|
|
||||||
|
|
||||||
|
def post(req: func.HttpRequest) -> func.HttpResponse:
|
||||||
|
request = parse_request(InstanceConfigUpdate, req)
|
||||||
|
if isinstance(request, Error):
|
||||||
|
return not_ok(request, context="instance_config_update")
|
||||||
|
|
||||||
|
config = InstanceConfig.fetch()
|
||||||
|
|
||||||
|
if not can_modify_config(req, config):
|
||||||
|
return not_ok(
|
||||||
|
Error(code=ErrorCode.INVALID_PERMISSION, errors=["unauthorized"]),
|
||||||
|
context="instance_config_update",
|
||||||
|
)
|
||||||
|
|
||||||
|
config.update(request.config)
|
||||||
|
config.save()
|
||||||
|
return ok(config)
|
||||||
|
|
||||||
|
|
||||||
|
def main(req: func.HttpRequest, dashboard: func.Out[str]) -> func.HttpResponse:
|
||||||
|
methods = {"GET": get, "POST": post}
|
||||||
|
method = methods[req.method]
|
||||||
|
result = call_if_user(req, method)
|
||||||
|
|
||||||
|
events = get_events()
|
||||||
|
if events:
|
||||||
|
dashboard.set(events)
|
||||||
|
|
||||||
|
return result
|
26
src/api-service/__app__/instance_config/function.json
Normal file
26
src/api-service/__app__/instance_config/function.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"scriptFile": "__init__.py",
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"authLevel": "anonymous",
|
||||||
|
"type": "httpTrigger",
|
||||||
|
"direction": "in",
|
||||||
|
"name": "req",
|
||||||
|
"methods": [
|
||||||
|
"get",
|
||||||
|
"post"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"direction": "out",
|
||||||
|
"name": "$return"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "signalR",
|
||||||
|
"direction": "out",
|
||||||
|
"name": "dashboard",
|
||||||
|
"hubName": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
34
src/api-service/__app__/onefuzzlib/config.py
Normal file
34
src/api-service/__app__/onefuzzlib/config.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# Copyright (c) Microsoft Corporation.
|
||||||
|
# Licensed under the MIT License.
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from onefuzztypes.events import EventInstanceConfigUpdated
|
||||||
|
from onefuzztypes.models import InstanceConfig as BASE_CONFIG
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from .azure.creds import get_instance_name
|
||||||
|
from .events import send_event
|
||||||
|
from .orm import ORMMixin
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceConfig(BASE_CONFIG, ORMMixin):
|
||||||
|
instance_name: str = Field(default_factory=get_instance_name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def key_fields(cls) -> Tuple[str, Optional[str]]:
|
||||||
|
return ("instance_name", None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fetch(cls) -> "InstanceConfig":
|
||||||
|
entry = cls.get(get_instance_name())
|
||||||
|
if entry is None:
|
||||||
|
entry = cls()
|
||||||
|
entry.save()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def save(self, new: bool = False, require_etag: bool = False) -> None:
|
||||||
|
super().save(new=new, require_etag=require_etag)
|
||||||
|
send_event(EventInstanceConfigUpdated(config=self))
|
@ -4,7 +4,7 @@
|
|||||||
# Licensed under the MIT License.
|
# Licensed under the MIT License.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable
|
from typing import Callable, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import azure.functions as func
|
import azure.functions as func
|
||||||
@ -13,6 +13,7 @@ from onefuzztypes.enums import ErrorCode
|
|||||||
from onefuzztypes.models import Error, UserInfo
|
from onefuzztypes.models import Error, UserInfo
|
||||||
|
|
||||||
from .azure.creds import get_scaleset_principal_id
|
from .azure.creds import get_scaleset_principal_id
|
||||||
|
from .config import InstanceConfig
|
||||||
from .request import not_ok
|
from .request import not_ok
|
||||||
from .user_credentials import parse_jwt_token
|
from .user_credentials import parse_jwt_token
|
||||||
from .workers.pools import Pool
|
from .workers.pools import Pool
|
||||||
@ -43,6 +44,57 @@ def is_agent(token_data: UserInfo) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_modify_config_impl(config: InstanceConfig, user_info: UserInfo) -> bool:
|
||||||
|
if config.admins is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return user_info.object_id in config.admins
|
||||||
|
|
||||||
|
|
||||||
|
def can_modify_config(req: func.HttpRequest, config: InstanceConfig) -> bool:
|
||||||
|
user_info = parse_jwt_token(req)
|
||||||
|
if not isinstance(user_info, UserInfo):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return can_modify_config_impl(config, user_info)
|
||||||
|
|
||||||
|
|
||||||
|
def check_can_manage_pools_impl(
|
||||||
|
config: InstanceConfig, user_info: UserInfo
|
||||||
|
) -> Optional[Error]:
|
||||||
|
if config.allow_pool_management:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if config.admins is None:
|
||||||
|
return Error(code=ErrorCode.UNAUTHORIZED, errors=["pool modification disabled"])
|
||||||
|
|
||||||
|
if user_info.object_id in config.admins:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Error(code=ErrorCode.UNAUTHORIZED, errors=["not authorized to manage pools"])
|
||||||
|
|
||||||
|
|
||||||
|
def check_can_manage_pools(req: func.HttpRequest) -> Optional[Error]:
|
||||||
|
user_info = parse_jwt_token(req)
|
||||||
|
if isinstance(user_info, Error):
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
# When there are no admins in the `admins` list, all users are considered
|
||||||
|
# admins. However, `allow_pool_management` is still useful to protect from
|
||||||
|
# mistakes.
|
||||||
|
#
|
||||||
|
# To make changes while still protecting against accidental changes to
|
||||||
|
# pools, do the following:
|
||||||
|
#
|
||||||
|
# 1. set `allow_pool_management` to `True`
|
||||||
|
# 2. make the change
|
||||||
|
# 3. set `allow_pool_management` to `False`
|
||||||
|
|
||||||
|
config = InstanceConfig.fetch()
|
||||||
|
|
||||||
|
return check_can_manage_pools_impl(config, user_info)
|
||||||
|
|
||||||
|
|
||||||
def is_user(token_data: UserInfo) -> bool:
|
def is_user(token_data: UserInfo) -> bool:
|
||||||
return not is_agent(token_data)
|
return not is_agent(token_data)
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ from ..onefuzzlib.azure.creds import (
|
|||||||
from ..onefuzzlib.azure.queue import get_queue_sas
|
from ..onefuzzlib.azure.queue import get_queue_sas
|
||||||
from ..onefuzzlib.azure.storage import StorageType
|
from ..onefuzzlib.azure.storage import StorageType
|
||||||
from ..onefuzzlib.azure.vmss import list_available_skus
|
from ..onefuzzlib.azure.vmss import list_available_skus
|
||||||
from ..onefuzzlib.endpoint_authorization import call_if_user
|
from ..onefuzzlib.endpoint_authorization import call_if_user, check_can_manage_pools
|
||||||
from ..onefuzzlib.events import get_events
|
from ..onefuzzlib.events import get_events
|
||||||
from ..onefuzzlib.request import not_ok, ok, parse_request
|
from ..onefuzzlib.request import not_ok, ok, parse_request
|
||||||
from ..onefuzzlib.workers.pools import Pool
|
from ..onefuzzlib.workers.pools import Pool
|
||||||
@ -78,6 +78,10 @@ def post(req: func.HttpRequest) -> func.HttpResponse:
|
|||||||
if isinstance(request, Error):
|
if isinstance(request, Error):
|
||||||
return not_ok(request, context="PoolCreate")
|
return not_ok(request, context="PoolCreate")
|
||||||
|
|
||||||
|
answer = check_can_manage_pools(req)
|
||||||
|
if isinstance(answer, Error):
|
||||||
|
return not_ok(answer, context="PoolCreate")
|
||||||
|
|
||||||
pool = Pool.get_by_name(request.name)
|
pool = Pool.get_by_name(request.name)
|
||||||
if isinstance(pool, Pool):
|
if isinstance(pool, Pool):
|
||||||
return not_ok(
|
return not_ok(
|
||||||
@ -130,6 +134,10 @@ def delete(req: func.HttpRequest) -> func.HttpResponse:
|
|||||||
if isinstance(request, Error):
|
if isinstance(request, Error):
|
||||||
return not_ok(request, context="PoolDelete")
|
return not_ok(request, context="PoolDelete")
|
||||||
|
|
||||||
|
answer = check_can_manage_pools(req)
|
||||||
|
if isinstance(answer, Error):
|
||||||
|
return not_ok(answer, context="PoolDelete")
|
||||||
|
|
||||||
pool = Pool.get_by_name(request.name)
|
pool = Pool.get_by_name(request.name)
|
||||||
if isinstance(pool, Error):
|
if isinstance(pool, Error):
|
||||||
return not_ok(pool, context="pool stop")
|
return not_ok(pool, context="pool stop")
|
||||||
|
@ -16,7 +16,7 @@ from onefuzztypes.responses import BoolResult
|
|||||||
|
|
||||||
from ..onefuzzlib.azure.creds import get_base_region, get_regions
|
from ..onefuzzlib.azure.creds import get_base_region, get_regions
|
||||||
from ..onefuzzlib.azure.vmss import list_available_skus
|
from ..onefuzzlib.azure.vmss import list_available_skus
|
||||||
from ..onefuzzlib.endpoint_authorization import call_if_user
|
from ..onefuzzlib.endpoint_authorization import call_if_user, check_can_manage_pools
|
||||||
from ..onefuzzlib.events import get_events
|
from ..onefuzzlib.events import get_events
|
||||||
from ..onefuzzlib.request import not_ok, ok, parse_request
|
from ..onefuzzlib.request import not_ok, ok, parse_request
|
||||||
from ..onefuzzlib.workers.pools import Pool
|
from ..onefuzzlib.workers.pools import Pool
|
||||||
@ -48,6 +48,10 @@ def post(req: func.HttpRequest) -> func.HttpResponse:
|
|||||||
if isinstance(request, Error):
|
if isinstance(request, Error):
|
||||||
return not_ok(request, context="ScalesetCreate")
|
return not_ok(request, context="ScalesetCreate")
|
||||||
|
|
||||||
|
answer = check_can_manage_pools(req)
|
||||||
|
if isinstance(answer, Error):
|
||||||
|
return not_ok(answer, context="ScalesetCreate")
|
||||||
|
|
||||||
# Verify the pool exists
|
# Verify the pool exists
|
||||||
pool = Pool.get_by_name(request.pool_name)
|
pool = Pool.get_by_name(request.pool_name)
|
||||||
if isinstance(pool, Error):
|
if isinstance(pool, Error):
|
||||||
@ -105,6 +109,10 @@ def delete(req: func.HttpRequest) -> func.HttpResponse:
|
|||||||
if isinstance(request, Error):
|
if isinstance(request, Error):
|
||||||
return not_ok(request, context="ScalesetDelete")
|
return not_ok(request, context="ScalesetDelete")
|
||||||
|
|
||||||
|
answer = check_can_manage_pools(req)
|
||||||
|
if isinstance(answer, Error):
|
||||||
|
return not_ok(answer, context="ScalesetDelete")
|
||||||
|
|
||||||
scaleset = Scaleset.get_by_id(request.scaleset_id)
|
scaleset = Scaleset.get_by_id(request.scaleset_id)
|
||||||
if isinstance(scaleset, Error):
|
if isinstance(scaleset, Error):
|
||||||
return not_ok(scaleset, context="scaleset stop")
|
return not_ok(scaleset, context="scaleset stop")
|
||||||
@ -118,6 +126,10 @@ def patch(req: func.HttpRequest) -> func.HttpResponse:
|
|||||||
if isinstance(request, Error):
|
if isinstance(request, Error):
|
||||||
return not_ok(request, context="ScalesetUpdate")
|
return not_ok(request, context="ScalesetUpdate")
|
||||||
|
|
||||||
|
answer = check_can_manage_pools(req)
|
||||||
|
if isinstance(answer, Error):
|
||||||
|
return not_ok(answer, context="ScalesetUpdate")
|
||||||
|
|
||||||
scaleset = Scaleset.get_by_id(request.scaleset_id)
|
scaleset = Scaleset.get_by_id(request.scaleset_id)
|
||||||
if isinstance(scaleset, Error):
|
if isinstance(scaleset, Error):
|
||||||
return not_ok(scaleset, context="ScalesetUpdate")
|
return not_ok(scaleset, context="ScalesetUpdate")
|
||||||
|
97
src/api-service/tests/test_auth_check.py
Normal file
97
src/api-service/tests/test_auth_check.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# Copyright (c) Microsoft Corporation.
|
||||||
|
# Licensed under the MIT License.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from onefuzztypes.models import UserInfo
|
||||||
|
|
||||||
|
from __app__.onefuzzlib.config import InstanceConfig
|
||||||
|
from __app__.onefuzzlib.endpoint_authorization import (
|
||||||
|
can_modify_config_impl,
|
||||||
|
check_can_manage_pools_impl,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "ONEFUZZ_INSTANCE_NAME" not in os.environ:
|
||||||
|
os.environ["ONEFUZZ_INSTANCE_NAME"] = "test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdmin(unittest.TestCase):
|
||||||
|
def test_modify_config(self) -> None:
|
||||||
|
user1 = uuid4()
|
||||||
|
user2 = uuid4()
|
||||||
|
|
||||||
|
# no admins set
|
||||||
|
self.assertTrue(can_modify_config_impl(InstanceConfig(), UserInfo()))
|
||||||
|
|
||||||
|
# with oid, but no admin
|
||||||
|
self.assertTrue(
|
||||||
|
can_modify_config_impl(InstanceConfig(), UserInfo(object_id=user1))
|
||||||
|
)
|
||||||
|
|
||||||
|
# is admin
|
||||||
|
self.assertTrue(
|
||||||
|
can_modify_config_impl(
|
||||||
|
InstanceConfig(admins=[user1]), UserInfo(object_id=user1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# no user oid set
|
||||||
|
self.assertFalse(
|
||||||
|
can_modify_config_impl(InstanceConfig(admins=[user1]), UserInfo())
|
||||||
|
)
|
||||||
|
|
||||||
|
# not an admin
|
||||||
|
self.assertFalse(
|
||||||
|
can_modify_config_impl(
|
||||||
|
InstanceConfig(admins=[user1]), UserInfo(object_id=user2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manage_pools(self) -> None:
|
||||||
|
user1 = uuid4()
|
||||||
|
user2 = uuid4()
|
||||||
|
|
||||||
|
# by default, any can modify
|
||||||
|
self.assertIsNone(
|
||||||
|
check_can_manage_pools_impl(
|
||||||
|
InstanceConfig(allow_pool_management=True), UserInfo()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# with oid, but no admin
|
||||||
|
self.assertIsNone(
|
||||||
|
check_can_manage_pools_impl(
|
||||||
|
InstanceConfig(allow_pool_management=True), UserInfo(object_id=user1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# is admin
|
||||||
|
self.assertIsNone(
|
||||||
|
check_can_manage_pools_impl(
|
||||||
|
InstanceConfig(allow_pool_management=False, admins=[user1]),
|
||||||
|
UserInfo(object_id=user1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# no user oid set
|
||||||
|
self.assertIsNotNone(
|
||||||
|
check_can_manage_pools_impl(
|
||||||
|
InstanceConfig(allow_pool_management=False, admins=[user1]), UserInfo()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# not an admin
|
||||||
|
self.assertIsNotNone(
|
||||||
|
check_can_manage_pools_impl(
|
||||||
|
InstanceConfig(allow_pool_management=False, admins=[user1]),
|
||||||
|
UserInfo(object_id=user2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
@ -1557,6 +1557,22 @@ class ScalesetProxy(Endpoint):
|
|||||||
return self._req_model("GET", responses.ProxyList, data=requests.ProxyGet())
|
return self._req_model("GET", responses.ProxyList, data=requests.ProxyGet())
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceConfigCmd(Endpoint):
|
||||||
|
"""Interact with Instance Configuration"""
|
||||||
|
|
||||||
|
endpoint = "instance_config"
|
||||||
|
|
||||||
|
def get(self) -> models.InstanceConfig:
|
||||||
|
return self._req_model("GET", models.InstanceConfig)
|
||||||
|
|
||||||
|
def update(self, config: models.InstanceConfig) -> models.InstanceConfig:
|
||||||
|
return self._req_model(
|
||||||
|
"POST",
|
||||||
|
models.InstanceConfig,
|
||||||
|
data=requests.InstanceConfigUpdate(config=config),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Command:
|
class Command:
|
||||||
def __init__(self, onefuzz: "Onefuzz", logger: logging.Logger):
|
def __init__(self, onefuzz: "Onefuzz", logger: logging.Logger):
|
||||||
self.onefuzz = onefuzz
|
self.onefuzz = onefuzz
|
||||||
@ -1634,6 +1650,7 @@ class Onefuzz:
|
|||||||
self.scalesets = Scaleset(self)
|
self.scalesets = Scaleset(self)
|
||||||
self.nodes = Node(self)
|
self.nodes = Node(self)
|
||||||
self.webhooks = Webhooks(self)
|
self.webhooks = Webhooks(self)
|
||||||
|
self.instance_config = InstanceConfigCmd(self)
|
||||||
|
|
||||||
if self._backend.is_feature_enabled(PreviewFeature.job_templates.name):
|
if self._backend.is_feature_enabled(PreviewFeature.job_templates.name):
|
||||||
self.job_templates = JobTemplates(self)
|
self.job_templates = JobTemplates(self)
|
||||||
|
@ -72,6 +72,7 @@ from registration import (
|
|||||||
set_app_audience,
|
set_app_audience,
|
||||||
update_pool_registration,
|
update_pool_registration,
|
||||||
)
|
)
|
||||||
|
from set_admins import update_admins
|
||||||
|
|
||||||
# Found by manually assigning the User.Read permission to application
|
# Found by manually assigning the User.Read permission to application
|
||||||
# registration in the admin portal. The values are in the manifest under
|
# registration in the admin portal. The values are in the manifest under
|
||||||
@ -129,6 +130,7 @@ class Client:
|
|||||||
multi_tenant_domain: str,
|
multi_tenant_domain: str,
|
||||||
upgrade: bool,
|
upgrade: bool,
|
||||||
subscription_id: Optional[str],
|
subscription_id: Optional[str],
|
||||||
|
admins: List[UUID]
|
||||||
):
|
):
|
||||||
self.subscription_id = subscription_id
|
self.subscription_id = subscription_id
|
||||||
self.resource_group = resource_group
|
self.resource_group = resource_group
|
||||||
@ -158,6 +160,7 @@ class Client:
|
|||||||
self.migrations = migrations
|
self.migrations = migrations
|
||||||
self.export_appinsights = export_appinsights
|
self.export_appinsights = export_appinsights
|
||||||
self.log_service_principal = log_service_principal
|
self.log_service_principal = log_service_principal
|
||||||
|
self.admins = admins
|
||||||
|
|
||||||
machine = platform.machine()
|
machine = platform.machine()
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
@ -552,12 +555,18 @@ class Client:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def apply_migrations(self) -> None:
|
def apply_migrations(self) -> None:
|
||||||
self.results["deploy"]["func-storage"]["value"]
|
|
||||||
name = self.results["deploy"]["func-name"]["value"]
|
name = self.results["deploy"]["func-name"]["value"]
|
||||||
key = self.results["deploy"]["func-key"]["value"]
|
key = self.results["deploy"]["func-key"]["value"]
|
||||||
table_service = TableService(account_name=name, account_key=key)
|
table_service = TableService(account_name=name, account_key=key)
|
||||||
migrate(table_service, self.migrations)
|
migrate(table_service, self.migrations)
|
||||||
|
|
||||||
|
def set_admins(self) -> None:
|
||||||
|
name = self.results["deploy"]["func-name"]["value"]
|
||||||
|
key = self.results["deploy"]["func-key"]["value"]
|
||||||
|
table_service = TableService(account_name=name, account_key=key)
|
||||||
|
if self.admins:
|
||||||
|
update_admins(table_service, self.application_name, self.admins)
|
||||||
|
|
||||||
def create_queues(self) -> None:
|
def create_queues(self) -> None:
|
||||||
logger.info("creating eventgrid destination queue")
|
logger.info("creating eventgrid destination queue")
|
||||||
|
|
||||||
@ -916,6 +925,7 @@ def main() -> None:
|
|||||||
|
|
||||||
full_deployment_states = rbac_only_states + [
|
full_deployment_states = rbac_only_states + [
|
||||||
("apply_migrations", Client.apply_migrations),
|
("apply_migrations", Client.apply_migrations),
|
||||||
|
("set_admins", Client.set_admins),
|
||||||
("queues", Client.create_queues),
|
("queues", Client.create_queues),
|
||||||
("eventgrid", Client.create_eventgrid),
|
("eventgrid", Client.create_eventgrid),
|
||||||
("tools", Client.upload_tools),
|
("tools", Client.upload_tools),
|
||||||
@ -1021,6 +1031,12 @@ def main() -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="execute only the steps required to create the rbac resources",
|
help="execute only the steps required to create the rbac resources",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--set_admins",
|
||||||
|
type=UUID,
|
||||||
|
nargs="*",
|
||||||
|
help="set the list of administrators (by OID in AAD)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@ -1048,6 +1064,7 @@ def main() -> None:
|
|||||||
multi_tenant_domain=args.multi_tenant_domain,
|
multi_tenant_domain=args.multi_tenant_domain,
|
||||||
upgrade=args.upgrade,
|
upgrade=args.upgrade,
|
||||||
subscription_id=args.subscription_id,
|
subscription_id=args.subscription_id,
|
||||||
|
admins=args.set_admins,
|
||||||
)
|
)
|
||||||
if args.verbose:
|
if args.verbose:
|
||||||
level = logging.DEBUG
|
level = logging.DEBUG
|
||||||
|
60
src/deployment/set_admins.py
Normal file
60
src/deployment/set_admins.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# Copyright (c) Microsoft Corporation.
|
||||||
|
# Licensed under the MIT License.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from azure.common.client_factory import get_client_from_cli_profile
|
||||||
|
from azure.cosmosdb.table.tableservice import TableService
|
||||||
|
from azure.mgmt.storage import StorageManagementClient
|
||||||
|
|
||||||
|
TABLE_NAME = "InstanceConfig"
|
||||||
|
|
||||||
|
|
||||||
|
def create_if_missing(table_service: TableService) -> None:
|
||||||
|
if not table_service.exists(TABLE_NAME):
|
||||||
|
table_service.create_table(TABLE_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def update_admins(
|
||||||
|
table_service: TableService, resource_group: str, admins: List[UUID]
|
||||||
|
) -> None:
|
||||||
|
create_if_missing(table_service)
|
||||||
|
admins_as_str: Optional[List[str]] = None
|
||||||
|
if admins:
|
||||||
|
admins_as_str = [str(x) for x in admins]
|
||||||
|
|
||||||
|
table_service.insert_or_merge_entity(
|
||||||
|
TABLE_NAME,
|
||||||
|
{
|
||||||
|
"PartitionKey": resource_group,
|
||||||
|
"RowKey": resource_group,
|
||||||
|
"admins": json.dumps(admins_as_str),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
formatter = argparse.ArgumentDefaultsHelpFormatter
|
||||||
|
parser = argparse.ArgumentParser(formatter_class=formatter)
|
||||||
|
parser.add_argument("resource_group")
|
||||||
|
parser.add_argument("storage_account")
|
||||||
|
parser.add_argument("admins", type=UUID, nargs="*")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
client = get_client_from_cli_profile(StorageManagementClient)
|
||||||
|
storage_keys = client.storage_accounts.list_keys(
|
||||||
|
args.resource_group, args.storage_account
|
||||||
|
)
|
||||||
|
table_service = TableService(
|
||||||
|
account_name=args.storage_account, account_key=storage_keys.keys[0].value
|
||||||
|
)
|
||||||
|
update_admins(table_service, args.resource_group, args.admins)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
@ -22,6 +22,7 @@ from onefuzztypes.events import (
|
|||||||
Event,
|
Event,
|
||||||
EventCrashReported,
|
EventCrashReported,
|
||||||
EventFileAdded,
|
EventFileAdded,
|
||||||
|
EventInstanceConfigUpdated,
|
||||||
EventJobCreated,
|
EventJobCreated,
|
||||||
EventJobStopped,
|
EventJobStopped,
|
||||||
EventNodeCreated,
|
EventNodeCreated,
|
||||||
@ -53,6 +54,7 @@ from onefuzztypes.models import (
|
|||||||
BlobRef,
|
BlobRef,
|
||||||
CrashTestResult,
|
CrashTestResult,
|
||||||
Error,
|
Error,
|
||||||
|
InstanceConfig,
|
||||||
JobConfig,
|
JobConfig,
|
||||||
RegressionReport,
|
RegressionReport,
|
||||||
Report,
|
Report,
|
||||||
@ -252,6 +254,7 @@ def main() -> None:
|
|||||||
EventFileAdded(container=Container("container-name"), filename="example.txt"),
|
EventFileAdded(container=Container("container-name"), filename="example.txt"),
|
||||||
EventNodeHeartbeat(machine_id=UUID(int=0), pool_name=PoolName("example")),
|
EventNodeHeartbeat(machine_id=UUID(int=0), pool_name=PoolName("example")),
|
||||||
EventTaskHeartbeat(task_id=UUID(int=0), job_id=UUID(int=0), config=task_config),
|
EventTaskHeartbeat(task_id=UUID(int=0), job_id=UUID(int=0), config=task_config),
|
||||||
|
EventInstanceConfigUpdated(config=InstanceConfig(admins=[UUID(int=0)])),
|
||||||
]
|
]
|
||||||
|
|
||||||
# works around `mypy` not handling that Union has `__args__`
|
# works around `mypy` not handling that Union has `__args__`
|
||||||
|
@ -23,6 +23,7 @@ from .enums import (
|
|||||||
from .models import (
|
from .models import (
|
||||||
AutoScaleConfig,
|
AutoScaleConfig,
|
||||||
Error,
|
Error,
|
||||||
|
InstanceConfig,
|
||||||
JobConfig,
|
JobConfig,
|
||||||
RegressionReport,
|
RegressionReport,
|
||||||
Report,
|
Report,
|
||||||
@ -200,6 +201,10 @@ class EventFileAdded(BaseEvent):
|
|||||||
filename: str
|
filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class EventInstanceConfigUpdated(BaseEvent):
|
||||||
|
config: InstanceConfig
|
||||||
|
|
||||||
|
|
||||||
Event = Union[
|
Event = Union[
|
||||||
EventJobCreated,
|
EventJobCreated,
|
||||||
EventJobStopped,
|
EventJobStopped,
|
||||||
@ -226,6 +231,7 @@ Event = Union[
|
|||||||
EventCrashReported,
|
EventCrashReported,
|
||||||
EventRegressionReported,
|
EventRegressionReported,
|
||||||
EventFileAdded,
|
EventFileAdded,
|
||||||
|
EventInstanceConfigUpdated,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -255,6 +261,7 @@ class EventType(Enum):
|
|||||||
file_added = "file_added"
|
file_added = "file_added"
|
||||||
task_heartbeat = "task_heartbeat"
|
task_heartbeat = "task_heartbeat"
|
||||||
node_heartbeat = "node_heartbeat"
|
node_heartbeat = "node_heartbeat"
|
||||||
|
instance_config_updated = "instance_config_updated"
|
||||||
|
|
||||||
|
|
||||||
EventTypeMap = {
|
EventTypeMap = {
|
||||||
@ -283,6 +290,7 @@ EventTypeMap = {
|
|||||||
EventType.crash_reported: EventCrashReported,
|
EventType.crash_reported: EventCrashReported,
|
||||||
EventType.regression_reported: EventRegressionReported,
|
EventType.regression_reported: EventRegressionReported,
|
||||||
EventType.file_added: EventFileAdded,
|
EventType.file_added: EventFileAdded,
|
||||||
|
EventType.instance_config_updated: EventInstanceConfigUpdated,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -856,4 +856,28 @@ class Task(BaseModel):
|
|||||||
user_info: Optional[UserInfo]
|
user_info: Optional[UserInfo]
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceConfig(BaseModel):
|
||||||
|
# initial set of admins can only be set during deployment.
|
||||||
|
# if admins are set, only admins can update instance configs.
|
||||||
|
admins: Optional[List[UUID]] = None
|
||||||
|
|
||||||
|
# if set, only admins can manage pools or scalesets
|
||||||
|
allow_pool_management: bool = Field(default=True)
|
||||||
|
|
||||||
|
def update(self, config: "InstanceConfig") -> None:
|
||||||
|
for field in config.__fields__:
|
||||||
|
# If no admins are set, then ignore setting admins
|
||||||
|
if field == "admins" and self.admins is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if hasattr(self, field):
|
||||||
|
setattr(self, field, getattr(config, field))
|
||||||
|
|
||||||
|
@validator("admins", allow_reuse=True)
|
||||||
|
def check_admins(cls, value: Optional[List[UUID]]) -> Optional[List[UUID]]:
|
||||||
|
if value is not None and len(value) == 0:
|
||||||
|
raise ValueError("admins must be None or contain at least one UUID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
_check_hotfix()
|
_check_hotfix()
|
||||||
|
@ -20,7 +20,7 @@ from .enums import (
|
|||||||
TaskState,
|
TaskState,
|
||||||
)
|
)
|
||||||
from .events import EventType
|
from .events import EventType
|
||||||
from .models import AutoScaleConfig, NotificationConfig
|
from .models import AutoScaleConfig, InstanceConfig, NotificationConfig
|
||||||
from .primitives import Container, PoolName, Region
|
from .primitives import Container, PoolName, Region
|
||||||
|
|
||||||
|
|
||||||
@ -253,4 +253,8 @@ class NodeAddSshKey(BaseModel):
|
|||||||
public_key: str
|
public_key: str
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceConfigUpdate(BaseModel):
|
||||||
|
config: InstanceConfig
|
||||||
|
|
||||||
|
|
||||||
_check_hotfix()
|
_check_hotfix()
|
||||||
|
30
src/pytypes/tests/test_instance_config_update.py
Normal file
30
src/pytypes/tests/test_instance_config_update.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# Copyright (c) Microsoft Corporation.
|
||||||
|
# Licensed under the MIT License.
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from onefuzztypes.models import InstanceConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstanceConfig(unittest.TestCase):
|
||||||
|
def test_with_admins(self) -> None:
|
||||||
|
no_admins = InstanceConfig(admins=None)
|
||||||
|
with_admins = InstanceConfig(admins=[UUID(int=0)])
|
||||||
|
with_admins_2 = InstanceConfig(admins=[UUID(int=1)])
|
||||||
|
|
||||||
|
no_admins.update(with_admins)
|
||||||
|
self.assertEqual(no_admins.admins, None)
|
||||||
|
|
||||||
|
with_admins.update(with_admins_2)
|
||||||
|
self.assertEqual(with_admins.admins, with_admins_2.admins)
|
||||||
|
|
||||||
|
def test_with_empty_admins(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
InstanceConfig.parse_obj({"admins": []})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
Reference in New Issue
Block a user