instance wide configuration (#1010)

TODO:
* [x] add setting initial set of admins during deployment
This commit is contained in:
bmc-msft
2021-06-30 17:13:58 -04:00
committed by GitHub
parent 1e90ed6092
commit 29dda54b83
16 changed files with 535 additions and 6 deletions

View File

@ -25,6 +25,7 @@ Each event will be submitted via HTTP POST to the user provided URL.
* [crash_reported](#crash_reported)
* [file_added](#file_added)
* [instance_config_updated](#instance_config_updated)
* [job_created](#job_created)
* [job_stopped](#job_stopped)
* [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
#### Example
@ -4792,6 +4846,18 @@ Each event will be submitted via HTTP POST to the user provided URL.
"title": "EventFileAdded",
"type": "object"
},
"EventInstanceConfigUpdated": {
"properties": {
"config": {
"$ref": "#/definitions/InstanceConfig"
}
},
"required": [
"config"
],
"title": "EventInstanceConfigUpdated",
"type": "object"
},
"EventJobCreated": {
"properties": {
"config": {
@ -5376,10 +5442,30 @@ Each event will be submitted via HTTP POST to the user provided URL.
"regression_reported",
"file_added",
"task_heartbeat",
"node_heartbeat"
"node_heartbeat",
"instance_config_updated"
],
"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": {
"properties": {
"build": {
@ -6071,6 +6157,9 @@ Each event will be submitted via HTTP POST to the user provided URL.
},
{
"$ref": "#/definitions/EventFileAdded"
},
{
"$ref": "#/definitions/EventInstanceConfigUpdated"
}
],
"title": "Event"

View 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

View 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"
}
]
}

View 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))

View File

@ -4,7 +4,7 @@
# Licensed under the MIT License.
import logging
from typing import Callable
from typing import Callable, Optional
from uuid import UUID
import azure.functions as func
@ -13,6 +13,7 @@ from onefuzztypes.enums import ErrorCode
from onefuzztypes.models import Error, UserInfo
from .azure.creds import get_scaleset_principal_id
from .config import InstanceConfig
from .request import not_ok
from .user_credentials import parse_jwt_token
from .workers.pools import Pool
@ -43,6 +44,57 @@ def is_agent(token_data: UserInfo) -> bool:
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:
return not is_agent(token_data)

View File

@ -21,7 +21,7 @@ from ..onefuzzlib.azure.creds import (
from ..onefuzzlib.azure.queue import get_queue_sas
from ..onefuzzlib.azure.storage import StorageType
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.request import not_ok, ok, parse_request
from ..onefuzzlib.workers.pools import Pool
@ -78,6 +78,10 @@ def post(req: func.HttpRequest) -> func.HttpResponse:
if isinstance(request, Error):
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)
if isinstance(pool, Pool):
return not_ok(
@ -130,6 +134,10 @@ def delete(req: func.HttpRequest) -> func.HttpResponse:
if isinstance(request, Error):
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)
if isinstance(pool, Error):
return not_ok(pool, context="pool stop")

View File

@ -16,7 +16,7 @@ from onefuzztypes.responses import BoolResult
from ..onefuzzlib.azure.creds import get_base_region, get_regions
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.request import not_ok, ok, parse_request
from ..onefuzzlib.workers.pools import Pool
@ -48,6 +48,10 @@ def post(req: func.HttpRequest) -> func.HttpResponse:
if isinstance(request, Error):
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
pool = Pool.get_by_name(request.pool_name)
if isinstance(pool, Error):
@ -105,6 +109,10 @@ def delete(req: func.HttpRequest) -> func.HttpResponse:
if isinstance(request, Error):
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)
if isinstance(scaleset, Error):
return not_ok(scaleset, context="scaleset stop")
@ -118,6 +126,10 @@ def patch(req: func.HttpRequest) -> func.HttpResponse:
if isinstance(request, Error):
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)
if isinstance(scaleset, Error):
return not_ok(scaleset, context="ScalesetUpdate")

View 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()

View File

@ -1557,6 +1557,22 @@ class ScalesetProxy(Endpoint):
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:
def __init__(self, onefuzz: "Onefuzz", logger: logging.Logger):
self.onefuzz = onefuzz
@ -1634,6 +1650,7 @@ class Onefuzz:
self.scalesets = Scaleset(self)
self.nodes = Node(self)
self.webhooks = Webhooks(self)
self.instance_config = InstanceConfigCmd(self)
if self._backend.is_feature_enabled(PreviewFeature.job_templates.name):
self.job_templates = JobTemplates(self)

View File

@ -72,6 +72,7 @@ from registration import (
set_app_audience,
update_pool_registration,
)
from set_admins import update_admins
# Found by manually assigning the User.Read permission to application
# registration in the admin portal. The values are in the manifest under
@ -129,6 +130,7 @@ class Client:
multi_tenant_domain: str,
upgrade: bool,
subscription_id: Optional[str],
admins: List[UUID]
):
self.subscription_id = subscription_id
self.resource_group = resource_group
@ -158,6 +160,7 @@ class Client:
self.migrations = migrations
self.export_appinsights = export_appinsights
self.log_service_principal = log_service_principal
self.admins = admins
machine = platform.machine()
system = platform.system()
@ -552,12 +555,18 @@ class Client:
)
def apply_migrations(self) -> None:
self.results["deploy"]["func-storage"]["value"]
name = self.results["deploy"]["func-name"]["value"]
key = self.results["deploy"]["func-key"]["value"]
table_service = TableService(account_name=name, account_key=key)
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:
logger.info("creating eventgrid destination queue")
@ -916,6 +925,7 @@ def main() -> None:
full_deployment_states = rbac_only_states + [
("apply_migrations", Client.apply_migrations),
("set_admins", Client.set_admins),
("queues", Client.create_queues),
("eventgrid", Client.create_eventgrid),
("tools", Client.upload_tools),
@ -1021,6 +1031,12 @@ def main() -> None:
action="store_true",
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()
@ -1048,6 +1064,7 @@ def main() -> None:
multi_tenant_domain=args.multi_tenant_domain,
upgrade=args.upgrade,
subscription_id=args.subscription_id,
admins=args.set_admins,
)
if args.verbose:
level = logging.DEBUG

View 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()

View File

@ -22,6 +22,7 @@ from onefuzztypes.events import (
Event,
EventCrashReported,
EventFileAdded,
EventInstanceConfigUpdated,
EventJobCreated,
EventJobStopped,
EventNodeCreated,
@ -53,6 +54,7 @@ from onefuzztypes.models import (
BlobRef,
CrashTestResult,
Error,
InstanceConfig,
JobConfig,
RegressionReport,
Report,
@ -252,6 +254,7 @@ def main() -> None:
EventFileAdded(container=Container("container-name"), filename="example.txt"),
EventNodeHeartbeat(machine_id=UUID(int=0), pool_name=PoolName("example")),
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__`

View File

@ -23,6 +23,7 @@ from .enums import (
from .models import (
AutoScaleConfig,
Error,
InstanceConfig,
JobConfig,
RegressionReport,
Report,
@ -200,6 +201,10 @@ class EventFileAdded(BaseEvent):
filename: str
class EventInstanceConfigUpdated(BaseEvent):
config: InstanceConfig
Event = Union[
EventJobCreated,
EventJobStopped,
@ -226,6 +231,7 @@ Event = Union[
EventCrashReported,
EventRegressionReported,
EventFileAdded,
EventInstanceConfigUpdated,
]
@ -255,6 +261,7 @@ class EventType(Enum):
file_added = "file_added"
task_heartbeat = "task_heartbeat"
node_heartbeat = "node_heartbeat"
instance_config_updated = "instance_config_updated"
EventTypeMap = {
@ -283,6 +290,7 @@ EventTypeMap = {
EventType.crash_reported: EventCrashReported,
EventType.regression_reported: EventRegressionReported,
EventType.file_added: EventFileAdded,
EventType.instance_config_updated: EventInstanceConfigUpdated,
}

View File

@ -856,4 +856,28 @@ class Task(BaseModel):
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()

View File

@ -20,7 +20,7 @@ from .enums import (
TaskState,
)
from .events import EventType
from .models import AutoScaleConfig, NotificationConfig
from .models import AutoScaleConfig, InstanceConfig, NotificationConfig
from .primitives import Container, PoolName, Region
@ -253,4 +253,8 @@ class NodeAddSshKey(BaseModel):
public_key: str
class InstanceConfigUpdate(BaseModel):
config: InstanceConfig
_check_hotfix()

View 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()