diff --git a/reboot/demos/fig/backend/src/main.ts b/reboot/demos/fig/backend/src/main.ts index 9f4de37a..af41c7a5 100644 --- a/reboot/demos/fig/backend/src/main.ts +++ b/reboot/demos/fig/backend/src/main.ts @@ -1,5 +1,5 @@ -import { Application, ServicerFactory } from "@reboot-dev/reboot"; -import presence from "@reboot-dev/reboot-std/presence/v1"; +import { Application, allow } from "@reboot-dev/reboot"; +import { presenceLibrary } from "@reboot-dev/reboot-std/presence/v1"; import { FigBoard } from "../../api/fig/v1/fig_rbt.js"; import { Users } from "../../api/user/v1/user_rbt.js"; import { FIG_BOARD_ID, USERS_ID } from "../../constants.js"; @@ -13,13 +13,10 @@ const initialize = async (context) => { }; new Application({ - servicers: [ - FigServicer, - FigBoardServicer, - UsersServicer, - UserServicer, + servicers: [FigServicer, FigBoardServicer, UsersServicer, UserServicer], + libraries: [ // We use `Presence` from the std library. - ...presence.servicers(), + presenceLibrary({ authorizer: allow() }), ], initialize, }).run(); diff --git a/reboot/examples/kcdc-2025/backend/src/main.py b/reboot/examples/kcdc-2025/backend/src/main.py index f011d1bd..3d079ae5 100644 --- a/reboot/examples/kcdc-2025/backend/src/main.py +++ b/reboot/examples/kcdc-2025/backend/src/main.py @@ -2,9 +2,10 @@ from chat.v1.channel_rbt import Channel from chatbot.v1.chatbot_rbt import Chatbot from reboot.aio.applications import Application +from reboot.aio.auth.authorizers import allow from reboot.std.collections.queue.v1.queue import QueueServicer from reboot.std.index.v1 import index -from reboot.std.presence.v1 import presence +from reboot.std.presence.v1.presence import presence_library from reboot.std.pubsub.v1.pubsub import PubSubServicer from servicers.channel import ChannelServicer from servicers.chatbot import ChatbotServicer @@ -42,7 +43,8 @@ async def main(): ChatbotServicer, QueueServicer, PubSubServicer, - ] + index.servicers() + presence.servicers(), + ] + index.servicers(), + libraries=[presence_library(authorizer=allow())], initialize=initialize, ).run() diff --git a/reboot/nodejs/index.ts b/reboot/nodejs/index.ts index a1a585e1..65f30f7f 100644 --- a/reboot/nodejs/index.ts +++ b/reboot/nodejs/index.ts @@ -890,7 +890,7 @@ export function isAppInternal({ export type NativeLibrary = { nativeLibraryModule: string; nativeLibraryFunction: string; - authorizer?: Authorizer; + authorizers?: Record>; }; export type TypeScriptLibrary = { diff --git a/reboot/nodejs/reboot_native.cc b/reboot/nodejs/reboot_native.cc index 5737dd9d..2ad49100 100644 --- a/reboot/nodejs/reboot_native.cc +++ b/reboot/nodejs/reboot_native.cc @@ -1546,7 +1546,7 @@ struct TypeScriptLibraryDetails { struct PythonNativeLibraryDetails { std::string py_library_module; std::string py_library_function; - std::optional js_authorizer; + std::map js_authorizers; }; using LibraryDetails = @@ -1566,16 +1566,26 @@ std::vector> make_library_details( .As() .Utf8Value(); - // Get authorizer. - std::optional js_authorizer; - if (!js_library.Get("authorizer").IsUndefined()) { - js_authorizer = NapiSafeObjectReference( - js_library.Get("authorizer").As()); + // Get authorizers. + std::map js_authorizers; + if (!js_library.Get("authorizers").IsUndefined()) { + Napi::Object js_authorizers_object = + js_library.Get("authorizers").As(); + Napi::Array keys = js_authorizers_object.GetPropertyNames(); + for (size_t i = 0; i < keys.Length(); ++i) { + std::string key = keys.Get(i).As().Utf8Value(); + js_authorizers.emplace( + key, + NapiSafeObjectReference( + js_authorizers_object.Get(key).As())); + } } library_details.push_back( - std::make_shared( - PythonNativeLibraryDetails{module, function, js_authorizer})); + std::make_shared(PythonNativeLibraryDetails{ + module, + function, + std::move(js_authorizers)})); } else if ( !js_library.Get("name").IsUndefined() && !js_library.Get("servicers").IsUndefined()) { @@ -1706,19 +1716,15 @@ py::list make_py_libraries( } else if ( auto python_details = std::get_if(details.get())) { - // Call the library() function with `authorizer` if one is provided. - py::object py_library; - if (python_details->js_authorizer.has_value()) { - py_library = - py::module::import(python_details->py_library_module.c_str()) - .attr(python_details->py_library_function.c_str())( - "authorizer"_a = - make_py_authorizer(*python_details->js_authorizer)); - } else { - py_library = - py::module::import(python_details->py_library_module.c_str()) - .attr(python_details->py_library_function.c_str())(); + // Call the library() function, passing any configured authorizers + // as keyword arguments. + py::dict kwargs; + for (const auto& [key, js_authorizer] : python_details->js_authorizers) { + kwargs[py::str(key)] = make_py_authorizer(js_authorizer); } + py::object py_library = + py::module::import(python_details->py_library_module.c_str()) + .attr(python_details->py_library_function.c_str())(**kwargs); py_libraries.append(py_library); } } diff --git a/reboot/plugin/skills/upgrade/migrations/next/presence-library-default-authorization.md b/reboot/plugin/skills/upgrade/migrations/next/presence-library-default-authorization.md new file mode 100644 index 00000000..715f69b8 --- /dev/null +++ b/reboot/plugin/skills/upgrade/migrations/next/presence-library-default-authorization.md @@ -0,0 +1,35 @@ +## Presence Library: Change in API, Change in default authorization + +This is the one migration note for changes in the presence library. + +### `presence.servicers()` deprecated in favor of `presence_library()` + +Instead of adding `presence.servicers()` in the list of `servicers` +passed to `Application`, either include `presence_library()` (for Python) or +`presenceLibrary()` (for Typescript) in the list of `libraries` +passed to `Application`. + +In Python, `presence_library()` is imported from `reboot.std.presence.v1.presence`. + +In Typescript, `presenceLibrary()` is imported from `"@reboot-dev/reboot-std/presence/v1"`. + +### Change in default authorization. + +Previously, the default authorization for servicers in the presence library was +to allow all calls. The default has now changed to allow calls that are internal +(`is_app_internal()`) or has a token verified by a `TokenVerifier` +()`has_verified_token()`). + +The best way to address this change is to introduce a `TokenVerifier` when +starting up your Reboot `Application`. + +To allow for anonymous presence, the recommendation is to have the +`TokenVerifier` present an anonymous `Auth()` object. Alternatively, the +`authorizer`s for the presence library by passing instantiating in Python with +`presence_library(authorizer=allow())` or in Typescript with +`presenceLibrary({ authorizer: allow() })`. + +Presence servicers can be individually configured by passing +`presence_authorizer`, `subscriber_authorizer`, and `mouse_position_authorizer` +in Python, or `presenceAuthorizer`, `subscriberAuthorizer`, and +`mousePositionAuthorizer` in Typescript. diff --git a/reboot/std/collections/ordered_map/v1/index.ts b/reboot/std/collections/ordered_map/v1/index.ts index b10ec6e5..88f3db02 100644 --- a/reboot/std/collections/ordered_map/v1/index.ts +++ b/reboot/std/collections/ordered_map/v1/index.ts @@ -1,5 +1,12 @@ -import { NativeLibrary, NativeServicer } from "@reboot-dev/reboot"; -import { OrderedMap } from "@reboot-dev/reboot-std-api/collections/ordered_map/v1/ordered_map_rbt.js"; +import { + AuthorizerRule, + NativeLibrary, + NativeServicer, +} from "@reboot-dev/reboot"; +import { + OrderedMap, + OrderedMapRequestTypes, +} from "@reboot-dev/reboot-std-api/collections/ordered_map/v1/ordered_map_rbt.js"; export * from "@reboot-dev/reboot-std-api/collections/ordered_map/v1/ordered_map_rbt.js"; export default { @@ -22,11 +29,20 @@ export function orderedMapLibrary({ // Just using `OrderedMap.Authorizer` results in ts(2749), "refers to a value, // but is being used as a type." `InstanceType` allows us to // refer to the type. - authorizer?: InstanceType; + authorizer?: + | InstanceType + | AuthorizerRule; } = {}): NativeLibrary { + const authorizers: NativeLibrary["authorizers"] = {}; + if (authorizer !== undefined) { + authorizers["authorizer"] = + authorizer instanceof AuthorizerRule + ? new OrderedMap.Authorizer({ _default: authorizer }) + : authorizer; + } return { nativeLibraryModule: "reboot.std.collections.ordered_map.v1.ordered_map", nativeLibraryFunction: "ordered_map_library", - authorizer, + authorizers, }; } diff --git a/reboot/std/collections/ordered_map/v1/ordered_map.py b/reboot/std/collections/ordered_map/v1/ordered_map.py index 3c950ba1..c6acfbf0 100644 --- a/reboot/std/collections/ordered_map/v1/ordered_map.py +++ b/reboot/std/collections/ordered_map/v1/ordered_map.py @@ -64,7 +64,11 @@ from rbt.std.item.v1.item_pb2 import Item from rbt.v1alpha1.errors_pb2 import InvalidArgument, StateAlreadyConstructed from reboot.aio.applications import Library -from reboot.aio.auth.authorizers import allow_if, is_app_internal +from reboot.aio.auth.authorizers import ( + AuthorizerRule, + allow_if, + is_app_internal, +) from reboot.aio.concurrently import concurrently from reboot.aio.contexts import ( ReaderContext, @@ -693,7 +697,7 @@ class OrderedMapServicer(OrderedMap.singleton.Servicer): # Singleton authorizer as class variable. # Discussion here for singleton authorizer vs subclassing the servicer: # https://github.com/reboot-dev/mono/pull/5140#issuecomment-3667592432 - _authorizer: Optional[OrderedMap.Authorizer] = None + _authorizer: Optional[OrderedMap.Authorizer | AuthorizerRule] = None def authorizer(self): if self._authorizer: @@ -1148,7 +1152,7 @@ class OrderedMapLibrary(Library): def __init__( self, - authorizer: Optional[OrderedMap.Authorizer] = None, + authorizer: Optional[OrderedMap.Authorizer | AuthorizerRule] = None, ): OrderedMapServicer._authorizer = authorizer @@ -1163,5 +1167,7 @@ def servicers(): ] -def ordered_map_library(authorizer: Optional[OrderedMap.Authorizer] = None): +def ordered_map_library( + authorizer: Optional[OrderedMap.Authorizer | AuthorizerRule] = None +): return OrderedMapLibrary(authorizer) diff --git a/reboot/std/collections/queue/v1/index.ts b/reboot/std/collections/queue/v1/index.ts index 27b344dc..cc207902 100644 --- a/reboot/std/collections/queue/v1/index.ts +++ b/reboot/std/collections/queue/v1/index.ts @@ -1,5 +1,12 @@ -import { NativeLibrary, NativeServicer } from "@reboot-dev/reboot"; -import { Queue } from "@reboot-dev/reboot-std-api/collections/queue/v1/queue_rbt.js"; +import { + AuthorizerRule, + NativeLibrary, + NativeServicer, +} from "@reboot-dev/reboot"; +import { + Queue, + QueueRequestTypes, +} from "@reboot-dev/reboot-std-api/collections/queue/v1/queue_rbt.js"; export * from "@reboot-dev/reboot-std-api/collections/queue/v1/queue_rbt.js"; export default { @@ -20,11 +27,20 @@ export function queueLibrary({ // Just using `Queue.Authorizer` results in ts(2749), "refers to a value, // but is being used as a type." `InstanceType` allows us to // refer to the type. - authorizer?: InstanceType; + authorizer?: + | InstanceType + | AuthorizerRule; } = {}): NativeLibrary { + const authorizers: NativeLibrary["authorizers"] = {}; + if (authorizer !== undefined) { + authorizers["authorizer"] = + authorizer instanceof AuthorizerRule + ? new Queue.Authorizer({ _default: authorizer }) + : authorizer; + } return { nativeLibraryModule: "reboot.std.collections.queue.v1.queue", nativeLibraryFunction: "queue_library", - authorizer, + authorizers, }; } diff --git a/reboot/std/collections/queue/v1/queue.py b/reboot/std/collections/queue/v1/queue.py index 7ba946a3..a122ab9f 100644 --- a/reboot/std/collections/queue/v1/queue.py +++ b/reboot/std/collections/queue/v1/queue.py @@ -8,7 +8,11 @@ Queue, ) from reboot.aio.applications import Library -from reboot.aio.auth.authorizers import allow_if, is_app_internal +from reboot.aio.auth.authorizers import ( + AuthorizerRule, + allow_if, + is_app_internal, +) from reboot.aio.contexts import ( ReaderContext, TransactionContext, @@ -35,7 +39,7 @@ class QueueServicer(Queue.Servicer): # Singleton authorizer as class variable. # Discussion here for singleton authorizer vs subclassing the servicer: # https://github.com/reboot-dev/mono/pull/5140#issuecomment-3667592432 - _authorizer: Optional[Queue.Authorizer] = None + _authorizer: Optional[Queue.Authorizer | AuthorizerRule] = None def authorizer(self): if self._authorizer: @@ -213,7 +217,7 @@ class QueueLibrary(Library): def __init__( self, - authorizer: Optional[Queue.Authorizer] = None, + authorizer: Optional[Queue.Authorizer | AuthorizerRule] = None, ): QueueServicer._authorizer = authorizer @@ -228,5 +232,7 @@ def servicers(): return [QueueServicer] + sorted_map.servicers() -def queue_library(authorizer: Optional[Queue.Authorizer] = None): +def queue_library( + authorizer: Optional[Queue.Authorizer | AuthorizerRule] = None +): return QueueLibrary(authorizer) diff --git a/reboot/std/collections/v1/sorted_map.py b/reboot/std/collections/v1/sorted_map.py index b276d8eb..9f583437 100644 --- a/reboot/std/collections/v1/sorted_map.py +++ b/reboot/std/collections/v1/sorted_map.py @@ -18,7 +18,11 @@ SortedMap, ) from reboot.aio.applications import Library -from reboot.aio.auth.authorizers import allow_if, is_app_internal +from reboot.aio.auth.authorizers import ( + AuthorizerRule, + allow_if, + is_app_internal, +) from reboot.aio.contexts import ReaderContext, WriterContext from typing import Optional @@ -30,7 +34,7 @@ class SortedMapServicer(SortedMap.singleton.Servicer): # Singleton authorizer as class variable. # Discussion here for singleton authorizer vs subclassing the servicer: # https://github.com/reboot-dev/mono/pull/5140#issuecomment-3667592432 - _authorizer: Optional[SortedMap.Authorizer] = None + _authorizer: Optional[SortedMap.Authorizer | AuthorizerRule] = None def authorizer(self): if self._authorizer: @@ -176,7 +180,7 @@ class SortedMapLibrary(Library): def __init__( self, - authorizer: Optional[SortedMap.Authorizer] = None, + authorizer: Optional[SortedMap.Authorizer | AuthorizerRule] = None, ): SortedMapServicer._authorizer = authorizer @@ -188,5 +192,7 @@ def servicers(): return [SortedMapServicer] -def sorted_map_library(authorizer: Optional[SortedMap.Authorizer] = None): +def sorted_map_library( + authorizer: Optional[SortedMap.Authorizer | AuthorizerRule] = None +): return SortedMapLibrary(authorizer) diff --git a/reboot/std/collections/v1/sorted_map.ts b/reboot/std/collections/v1/sorted_map.ts index 57815d1d..65c4f35e 100644 --- a/reboot/std/collections/v1/sorted_map.ts +++ b/reboot/std/collections/v1/sorted_map.ts @@ -1,5 +1,12 @@ -import { NativeLibrary, NativeServicer } from "@reboot-dev/reboot"; -import { SortedMap } from "@reboot-dev/reboot-std-api/collections/v1/sorted_map_rbt.js"; +import { + AuthorizerRule, + NativeLibrary, + NativeServicer, +} from "@reboot-dev/reboot"; +import { + SortedMap, + SortedMapRequestTypes, +} from "@reboot-dev/reboot-std-api/collections/v1/sorted_map_rbt.js"; export * from "@reboot-dev/reboot-std-api/collections/v1/sorted_map_rbt.js"; export default { @@ -20,11 +27,20 @@ export function sortedMapLibrary({ // Just using `SortedMap.Authorizer` results in ts(2749), "refers to a value, // but is being used as a type." `InstanceType` allows us to refer // to the type. - authorizer?: InstanceType; + authorizer?: + | InstanceType + | AuthorizerRule; } = {}): NativeLibrary { + const authorizers: NativeLibrary["authorizers"] = {}; + if (authorizer !== undefined) { + authorizers["authorizer"] = + authorizer instanceof AuthorizerRule + ? new SortedMap.Authorizer({ _default: authorizer }) + : authorizer; + } return { nativeLibraryModule: "reboot.std.collections.v1.sorted_map", nativeLibraryFunction: "sorted_map_library", - authorizer, + authorizers, }; } diff --git a/reboot/std/presence/v1/BUILD.bazel b/reboot/std/presence/v1/BUILD.bazel index efaa8dab..5540892f 100644 --- a/reboot/std/presence/v1/BUILD.bazel +++ b/reboot/std/presence/v1/BUILD.bazel @@ -8,6 +8,7 @@ py_library( ], visibility = ["//visibility:public"], deps = [ + "//reboot/aio:applications_py", "//reboot/aio:contexts_py", "//reboot/aio:memoize_py", "@com_github_reboot_dev_reboot//rbt/std/presence/mouse_tracker/v1:mouse_position_py_reboot", diff --git a/reboot/std/presence/v1/index.ts b/reboot/std/presence/v1/index.ts index 5e09c248..acf92219 100644 --- a/reboot/std/presence/v1/index.ts +++ b/reboot/std/presence/v1/index.ts @@ -1,4 +1,21 @@ -import { NativeServicer } from "@reboot-dev/reboot"; +import { + AuthorizerRule, + NativeLibrary, + NativeServicer, +} from "@reboot-dev/reboot"; +import { assert } from "@reboot-dev/reboot-api"; +import { + MousePosition, + MousePositionRequestTypes, +} from "@reboot-dev/reboot-std-api/presence/mouse_tracker/v1/mouse_position_rbt.js"; +import { + Subscriber, + SubscriberRequestTypes, +} from "@reboot-dev/reboot-std-api/presence/subscriber/v1/subscriber_rbt.js"; +import { + Presence, + PresenceRequestTypes, +} from "@reboot-dev/reboot-std-api/presence/v1/presence_rbt.js"; export * from "@reboot-dev/reboot-std-api/presence/v1/presence_rbt.js"; export default { @@ -10,3 +27,68 @@ export default { ]; }, }; + +export const PRESENCE_LIBRARY_NAME = "reboot.std.presence.v1.presence"; + +export function presenceLibrary({ + presenceAuthorizer, + subscriberAuthorizer, + mousePositionAuthorizer, + authorizer, +}: { + // Just using `Presence.Authorizer` results in ts(2749), "refers to a value, + // but is being used as a type." `InstanceType` allows us to + // refer to the type. + presenceAuthorizer?: + | InstanceType + | AuthorizerRule; + subscriberAuthorizer?: + | InstanceType + | AuthorizerRule; + mousePositionAuthorizer?: + | InstanceType + | AuthorizerRule; + authorizer?: AuthorizerRule; +} = {}): NativeLibrary { + // Check whether a blanket AuthorizationRule has been supplied. + if (authorizer !== undefined) { + assert( + presenceAuthorizer === undefined && + subscriberAuthorizer === undefined && + mousePositionAuthorizer === undefined, + "If an AuthorizerRule is supplied, it will be applied to all " + + "three servicers. To specify a specific authorizers for each " + + "servicer, please use pass in `presenceAuthorizer`, " + + "`subscriberAuthorizer` and `mousePositionAuthorizer`." + ); + presenceAuthorizer = authorizer; + subscriberAuthorizer = authorizer; + mousePositionAuthorizer = authorizer; + } + + // Set up `authorizers` to pass into Python. + const authorizers: NativeLibrary["authorizers"] = {}; + if (presenceAuthorizer !== undefined) { + authorizers["presence_authorizer"] = + presenceAuthorizer instanceof AuthorizerRule + ? new Presence.Authorizer({ _default: presenceAuthorizer }) + : presenceAuthorizer; + } + if (subscriberAuthorizer !== undefined) { + authorizers["subscriber_authorizer"] = + subscriberAuthorizer instanceof AuthorizerRule + ? new Subscriber.Authorizer({ _default: subscriberAuthorizer }) + : subscriberAuthorizer; + } + if (mousePositionAuthorizer !== undefined) { + authorizers["mouse_position_authorizer"] = + mousePositionAuthorizer instanceof AuthorizerRule + ? new MousePosition.Authorizer({ _default: mousePositionAuthorizer }) + : mousePositionAuthorizer; + } + return { + nativeLibraryModule: "reboot.std.presence.v1.presence", + nativeLibraryFunction: "presence_library", + authorizers, + }; +} diff --git a/reboot/std/presence/v1/presence.py b/reboot/std/presence/v1/presence.py index 69025d14..3257ed2d 100644 --- a/reboot/std/presence/v1/presence.py +++ b/reboot/std/presence/v1/presence.py @@ -29,9 +29,16 @@ WatchResponse, ) from rbt.v1alpha1.errors_pb2 import AlreadyExists, FailedPrecondition, NotFound -from reboot.aio.auth.authorizers import allow +from reboot.aio.applications import Library +from reboot.aio.auth.authorizers import ( + AuthorizerRule, + allow_if, + has_verified_token, + is_app_internal, +) from reboot.aio.contexts import ReaderContext, WorkflowContext, WriterContext from reboot.aio.workflows import until +from typing import Optional class Event: @@ -64,8 +71,14 @@ def set(self): class PresenceServicer(Presence.singleton.Servicer): + # Singleton authorizer as class variable. + _authorizer: Optional[Presence.Authorizer | AuthorizerRule] = None + def authorizer(self): - return allow() + if self._authorizer: + return self._authorizer + else: + return allow_if(any=[is_app_internal, has_verified_token]) async def Subscribe( self, @@ -131,8 +144,14 @@ class SubscriberServicer(Subscriber.singleton.Servicer): _disconnect_events: dict[str, Event] = {} + # Singleton authorizer as class variable. + _authorizer: Optional[Subscriber.Authorizer | AuthorizerRule] = None + def authorizer(self): - return allow() + if self._authorizer: + return self._authorizer + else: + return allow_if(any=[is_app_internal, has_verified_token]) async def Create( self, @@ -231,8 +250,14 @@ async def Status( class MousePositionServicer(MousePosition.singleton.Servicer): + # Singleton authorizer as class variable. + _authorizer: Optional[MousePosition.Authorizer | AuthorizerRule] = None + def authorizer(self): - return allow() + if self._authorizer: + return self._authorizer + else: + return allow_if(any=[is_app_internal, has_verified_token]) async def Update( self, @@ -258,9 +283,66 @@ async def Position( return PositionResponse(left=state.left, top=state.top) +PRESENCE_LIBRARY_NAME = "reboot.std.presence.v1.presence" + + +class PresenceLibrary(Library): + name = PRESENCE_LIBRARY_NAME + + def __init__( + self, + presence_authorizer: Optional[Presence.Authorizer | + AuthorizerRule] = None, + subscriber_authorizer: Optional[Subscriber.Authorizer | + AuthorizerRule] = None, + mouse_position_authorizer: Optional[MousePosition.Authorizer | + AuthorizerRule] = None, + authorizer: Optional[AuthorizerRule] = None, + ): + if authorizer is not None: + assert ( + presence_authorizer is None and + subscriber_authorizer is None and + mouse_position_authorizer is None + ), ( + "If `authorizer` as an `AuthorizerRule` is supplied, it will " + "be applied to all three servicers. To specify a specific " + "authorizers for each servicer, please use pass in " + "`presence_authorizer`, `subscriber_authorizer` and " + "`mouse_position_authorizer`." + ) + PresenceServicer._authorizer = authorizer + SubscriberServicer._authorizer = authorizer + MousePositionServicer._authorizer = authorizer + else: + PresenceServicer._authorizer = presence_authorizer + SubscriberServicer._authorizer = subscriber_authorizer + MousePositionServicer._authorizer = mouse_position_authorizer + + def servicers(self): + return [PresenceServicer, SubscriberServicer, MousePositionServicer] + + def servicers(): return [ PresenceServicer, SubscriberServicer, MousePositionServicer, ] + + +def presence_library( + presence_authorizer: Optional[Presence.Authorizer | AuthorizerRule] = None, + subscriber_authorizer: Optional[Subscriber.Authorizer | + AuthorizerRule] = None, + mouse_position_authorizer: Optional[MousePosition.Authorizer | + AuthorizerRule] = None, + authorizer: Optional[AuthorizerRule] = None, +) -> PresenceLibrary: + + return PresenceLibrary( + presence_authorizer, + subscriber_authorizer, + mouse_position_authorizer, + authorizer, + ) diff --git a/reboot/std/pubsub/v1/index.ts b/reboot/std/pubsub/v1/index.ts index 4996c1cb..7e39d3ba 100644 --- a/reboot/std/pubsub/v1/index.ts +++ b/reboot/std/pubsub/v1/index.ts @@ -1,5 +1,12 @@ -import { NativeLibrary, NativeServicer } from "@reboot-dev/reboot"; -import { Topic } from "@reboot-dev/reboot-std-api/pubsub/v1/pubsub_rbt.js"; +import { + AuthorizerRule, + NativeLibrary, + NativeServicer, +} from "@reboot-dev/reboot"; +import { + Topic, + TopicRequestTypes, +} from "@reboot-dev/reboot-std-api/pubsub/v1/pubsub_rbt.js"; export * from "@reboot-dev/reboot-std-api/pubsub/v1/pubsub_rbt.js"; export default { @@ -20,11 +27,20 @@ export function pubsubLibrary({ // Just using `Topic.Authorizer` results in ts(2749), "refers to a value, // but is being used as a type." `InstanceType` allows us to refer // to the type. - authorizer?: InstanceType; + authorizer?: + | InstanceType + | AuthorizerRule; } = {}): NativeLibrary { + const authorizers: NativeLibrary["authorizers"] = {}; + if (authorizer !== undefined) { + authorizers["authorizer"] = + authorizer instanceof AuthorizerRule + ? new Topic.Authorizer({ _default: authorizer }) + : authorizer; + } return { nativeLibraryModule: "reboot.std.pubsub.v1.pubsub", nativeLibraryFunction: "pubsub_library", - authorizer, + authorizers, }; } diff --git a/reboot/std/pubsub/v1/pubsub.py b/reboot/std/pubsub/v1/pubsub.py index 21f451ae..d5c57573 100644 --- a/reboot/std/pubsub/v1/pubsub.py +++ b/reboot/std/pubsub/v1/pubsub.py @@ -9,7 +9,7 @@ Topic, ) from reboot.aio.applications import Library -from reboot.aio.auth.authorizers import allow +from reboot.aio.auth.authorizers import AuthorizerRule, allow from reboot.aio.concurrently import concurrently from reboot.aio.contexts import WorkflowContext, WriterContext from reboot.aio.workflows import until @@ -24,7 +24,7 @@ class TopicServicer(Topic.Servicer): # Singleton authorizer as class variable. # Discussion here for singleton authorizer vs subclassing the servicer: # https://github.com/reboot-dev/mono/pull/5140#issuecomment-3667592432 - _authorizer: Optional[Topic.Authorizer] = None + _authorizer: Optional[Topic.Authorizer | AuthorizerRule] = None def authorizer(self): if self._authorizer: @@ -127,7 +127,7 @@ class PubSubLibrary(Library): def __init__( self, - authorizer: Optional[Topic.Authorizer] = None, + authorizer: Optional[Topic.Authorizer | AuthorizerRule] = None, ): TopicServicer._authorizer = authorizer @@ -142,5 +142,7 @@ def servicers(): return [TopicServicer] + queue.servicers() -def pubsub_library(authorizer: Optional[Topic.Authorizer] = None): +def pubsub_library( + authorizer: Optional[Topic.Authorizer | AuthorizerRule] = None +): return PubSubLibrary(authorizer) diff --git a/tests/reboot/nodejs/utils_test/BUILD.bazel b/tests/reboot/nodejs/utils_test/BUILD.bazel index fc5b0453..53d90f22 100644 --- a/tests/reboot/nodejs/utils_test/BUILD.bazel +++ b/tests/reboot/nodejs/utils_test/BUILD.bazel @@ -20,11 +20,7 @@ ts_project( deps = [ "//:node_modules/@reboot-dev/reboot", "//:node_modules/@reboot-dev/reboot-api", - # TODO: Although these dependencies are not used, the test will fail to - # build without them, claiming that something hasn't been declared a - # module. - "//tests/reboot:greeter_js_proto", - "//tests/reboot:greeter_js_reboot", + "//:node_modules/@types/node", ], ) diff --git a/tests/reboot/presence_main.py b/tests/reboot/presence_main.py index 4670e58b..584bd834 100644 --- a/tests/reboot/presence_main.py +++ b/tests/reboot/presence_main.py @@ -3,18 +3,19 @@ This is a main function for a container hosting only the `Presence` servicer. """ import asyncio -import reboot.std.presence.v1.presence from reboot.aio.applications import Application +from reboot.aio.auth.authorizers import allow from reboot.aio.memoize import MemoizeServicer +from reboot.std.presence.v1.presence import presence_library async def main(): application = Application( - servicers=[MemoizeServicer] + - reboot.std.presence.v1.presence.servicers(), + servicers=[MemoizeServicer], + libraries=[presence_library(authorizer=allow())], ) await application.run() if __name__ == '__main__': - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/tests/reboot/react/std/presence/test_against_local_envoy.py b/tests/reboot/react/std/presence/test_against_local_envoy.py index 235bea73..98ce3d76 100644 --- a/tests/reboot/react/std/presence/test_against_local_envoy.py +++ b/tests/reboot/react/std/presence/test_against_local_envoy.py @@ -1,6 +1,7 @@ -import reboot.std.presence.v1.presence from reboot.aio.applications import Application +from reboot.aio.auth.authorizers import allow from reboot.aio.memoize import MemoizeServicer +from reboot.std.presence.v1.presence import presence_library from tests.reboot.react.std.presence.test import test from tests.reboot.react.test_against_local_envoy import ( web_test_against_local_envoy, @@ -10,7 +11,7 @@ web_test_against_local_envoy( test=test, application=Application( - servicers=[MemoizeServicer] + - reboot.std.presence.v1.presence.servicers(), + servicers=[MemoizeServicer], + libraries=[presence_library(authorizer=allow())], ), - ) \ No newline at end of file + ) diff --git a/tests/reboot/std/collections/BUILD.bazel b/tests/reboot/std/collections/BUILD.bazel index cda7baa0..b73b8215 100644 --- a/tests/reboot/std/collections/BUILD.bazel +++ b/tests/reboot/std/collections/BUILD.bazel @@ -41,13 +41,9 @@ ts_project( visibility = ["//visibility:public"], deps = [ "//:node_modules/@reboot-dev/reboot", + "//:node_modules/@reboot-dev/reboot-api", "//:node_modules/@reboot-dev/reboot-std", "//:node_modules/@types/node", - # TODO: Although these dependencies are not used, the test will fail to - # build without them, claiming that something hasn't been declared a - # module. - "//tests/reboot:greeter_js_proto", - "//tests/reboot:greeter_js_reboot", ], ) diff --git a/tests/reboot/std/collections/sorted_map_tests.py b/tests/reboot/std/collections/sorted_map_tests.py index 613c89bd..8c61941c 100644 --- a/tests/reboot/std/collections/sorted_map_tests.py +++ b/tests/reboot/std/collections/sorted_map_tests.py @@ -726,6 +726,30 @@ async def test_overriding_auth(self) -> None: # https://github.com/reboot-dev/mono/pull/4601#issuecomment-2989091840 self.assertEqual(type(aborted.exception.error), PermissionDenied) + async def test_overriding_authorization_rule(self) -> None: + """ + Override library authorization with an AuthorizationRule. + """ + await self.rbt.up( + Application( + servicers=[SortedMapConsumer], + libraries=[sorted_map_library(allow())], + ), + local_envoy=True, + local_envoy_tls=True, # For TLS/SSL test coverage. + ) + + # Expect to be able to insert even with `app_internal=False`. + context = self.rbt.create_external_context( + name=f"test-{self.id()}-external", + app_internal=False, + ) + sorted_map = SortedMap.ref(self.id()) + await sorted_map.insert( + context, + entries={"a": b"1"}, + ) + async def test_servicers_back_compat(self) -> None: """ Test that we can start up `Application` with its `servicers()`. diff --git a/tests/reboot/std/collections/sorted_map_tests.ts b/tests/reboot/std/collections/sorted_map_tests.ts index 7970cadc..5cfe72aa 100644 --- a/tests/reboot/std/collections/sorted_map_tests.ts +++ b/tests/reboot/std/collections/sorted_map_tests.ts @@ -76,4 +76,21 @@ test("use std servicer", async (t) => { ); } }); + + await t.test("use overriding authorization rule", async (t) => { + await rbt.up( + new Application({ + libraries: [sortedMapLibrary({ authorizer: allow() })], + }) + ); + + // Expect to be able to insert even with `appInternal=false`. + const context = rbt.createExternalContext("test", { + appInternal: false, + }); + const myMap = SortedMap.ref("test-id"); + await myMap.insert(context, { + entries: { foo: new TextEncoder().encode("bar") }, + }); + }); }); diff --git a/tests/reboot/std/presence/BUILD.bazel b/tests/reboot/std/presence/BUILD.bazel index d1c3652a..e23fb162 100644 --- a/tests/reboot/std/presence/BUILD.bazel +++ b/tests/reboot/std/presence/BUILD.bazel @@ -30,6 +30,7 @@ ts_project( visibility = ["//visibility:public"], deps = [ "//:node_modules/@reboot-dev/reboot", + "//:node_modules/@reboot-dev/reboot-api", "//:node_modules/@reboot-dev/reboot-std", "//:node_modules/@types/node", ], diff --git a/tests/reboot/std/presence/presence_tests.py b/tests/reboot/std/presence/presence_tests.py index 8164adf1..812d93f2 100644 --- a/tests/reboot/std/presence/presence_tests.py +++ b/tests/reboot/std/presence/presence_tests.py @@ -1,8 +1,16 @@ import asyncio -import reboot.std.presence.v1.presence import unittest -from rbt.v1alpha1.errors_pb2 import AlreadyExists, FailedPrecondition, NotFound +from rbt.v1alpha1.errors_pb2 import ( + AlreadyExists, + FailedPrecondition, + NotFound, + PermissionDenied, +) from reboot.aio.applications import Application +from reboot.aio.auth import Auth +from reboot.aio.auth.authorizers import allow +from reboot.aio.auth.token_verifiers import TokenVerifier, VerifyTokenResult +from reboot.aio.contexts import ReaderContext from reboot.aio.external import ExternalContext from reboot.aio.memoize import MemoizeServicer from reboot.aio.tests import Reboot @@ -13,7 +21,20 @@ Presence, StatusResponse, Subscriber, + presence_library, ) +from typing import Optional + + +class EmptyTokenVerifier(TokenVerifier): + """Test token verifier that always returns that this is a valid Auth token.""" + + async def verify_token( + self, + context: ReaderContext, + token: Optional[str], + ) -> VerifyTokenResult: + return Auth(user_id=token or "default-user") class TestPresence(unittest.IsolatedAsyncioTestCase): @@ -21,12 +42,6 @@ class TestPresence(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: self.rbt = Reboot() await self.rbt.start() - await self.rbt.up( - Application( - servicers=reboot.std.presence.v1.presence.servicers() + - [MemoizeServicer] - ) - ) async def asyncTearDown(self) -> None: await self.rbt.stop() @@ -83,6 +98,14 @@ async def connect(): return connect_task async def test_mouse_position_basic(self) -> None: + await self.rbt.up( + Application( + servicers=[MemoizeServicer], + libraries=[presence_library()], + token_verifier=EmptyTokenVerifier(), + ) + ) + context = self.rbt.create_external_context(name=f"test-{self.id()}") mouse_position = MousePosition.ref("id") @@ -94,6 +117,14 @@ async def test_mouse_position_basic(self) -> None: self.assertEqual(response.top, 2) async def test_fail_toggle(self) -> None: + await self.rbt.up( + Application( + servicers=[MemoizeServicer], + libraries=[presence_library()], + token_verifier=EmptyTokenVerifier(), + ) + ) + context = self.rbt.create_external_context(name=f"test-{self.id()}") subscriber = Subscriber.ref("testing-toggle-fail") @@ -106,6 +137,14 @@ async def test_fail_toggle(self) -> None: self.assertTrue(isinstance(aborted.exception.error, NotFound)) async def test_fail_connect(self) -> None: + await self.rbt.up( + Application( + servicers=[MemoizeServicer], + libraries=[presence_library()], + token_verifier=EmptyTokenVerifier(), + ) + ) + context = self.rbt.create_external_context(name=f"test-{self.id()}") presence = Presence.ref("presence-testing-connect-fail") @@ -126,6 +165,14 @@ async def test_fail_connect(self) -> None: connect_task.cancel() async def test_fail_subscribe(self) -> None: + await self.rbt.up( + Application( + servicers=[MemoizeServicer], + libraries=[presence_library()], + token_verifier=EmptyTokenVerifier(), + ) + ) + context = self.rbt.create_external_context(name=f"test-{self.id()}") presence = Presence.ref("presence-testing-subscribe-fail") @@ -147,6 +194,14 @@ async def test_redundant_subscribe(self) -> None: Test that calling `Subscribe` multiple times with the same subscriber ID does not result in multiple subscribers being added to the list. """ + await self.rbt.up( + Application( + servicers=[MemoizeServicer], + libraries=[presence_library()], + token_verifier=EmptyTokenVerifier(), + ) + ) + context = self.rbt.create_external_context(name=f"test-{self.id()}") presence = Presence.ref("presence-testing-redundant-subscribe") @@ -192,6 +247,14 @@ async def test_single_subscriber(self) -> None: Test that we can successfully connect a single subscriber and verify that the state is updated correctly. """ + await self.rbt.up( + Application( + servicers=[MemoizeServicer], + libraries=[presence_library()], + token_verifier=EmptyTokenVerifier(), + ) + ) + context = self.rbt.create_external_context(name=f"test-{self.id()}") presence = Presence.ref("presence-testing-single-subscriber") @@ -228,6 +291,106 @@ async def test_single_subscriber(self) -> None: if response.subscriber_ids == []: break + async def test_no_token_verifier_fails_default_auth(self) -> None: + """ + Test that not using a TokenVerifier fails default authorization for + Presence. + """ + await self.rbt.up( + Application( + servicers=[MemoizeServicer], + libraries=[presence_library()], + ) + ) + + # Assert that Subscriber calls cannot be made. + context = self.rbt.create_external_context(name=f"test-{self.id()}-1") + with self.assertRaises(Subscriber.CreateAborted) as subscriber_aborted: + await Subscriber.ref("subscriber").idempotently().create(context) + self.assertTrue( + isinstance(subscriber_aborted.exception.error, PermissionDenied) + ) + + # Assert that Presence calls cannot be made. + # Needs own context due to idempotency logic given earlier call fails. + context = self.rbt.create_external_context(name=f"test-{self.id()}-2") + with self.assertRaises(Presence.SubscribeAborted) as presence_aborted: + await Presence.ref("presence" + ).subscribe(context, subscriber_id="subscriber") + self.assertTrue( + isinstance(presence_aborted.exception.error, PermissionDenied) + ) + + # Assert that MousePosition calls cannot be made. + # Needs own context due to idempotency logic given earlier calls fail. + context = self.rbt.create_external_context(name=f"test-{self.id()}-3") + with self.assertRaises( + MousePosition.UpdateAborted + ) as mouse_position_aborted: + await MousePosition.ref("subscriber").update( + context, + left=1, + top=1, + ) + self.assertTrue( + isinstance( + mouse_position_aborted.exception.error, PermissionDenied + ) + ) + + async def test_override_auth_allows(self) -> None: + """ + Test that overriding auth allows you to call into the library with a + token. + """ + await self.rbt.up( + Application( + servicers=[MemoizeServicer], + libraries=[presence_library(authorizer=allow())], + ) + ) + + context = self.rbt.create_external_context(name=f"test-{self.id()}") + + presence = Presence.ref("presence-testing-connect-fail") + subscriber = Subscriber.ref("subscriber-testing-connect-fail") + + nonce = "1" + + # This will test calling into Subscriber and Presence servicers. + connect_task = await self.make_connection( + presence, subscriber, context, nonce + ) + + # Check that calls into Mouse Position can be made. + await MousePosition.ref(subscriber.state_id).update( + context, + left=1, + top=1, + ) + + connect_task.cancel() + + async def test_pass_too_many_authorizers_assert(self) -> None: + """ + Test if you pass in authorizer and specific authorizers into + `presence_library` that it fails with an assert. + """ + with self.assertRaises(AssertionError): + await self.rbt.up( + Application( + servicers=[MemoizeServicer], + libraries=[ + presence_library( + authorizer=allow(), + presence_authorizer=Presence.Authorizer( + subscribe=allow() + ), + ) + ], + ) + ) + if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/reboot/std/presence/presence_tests.ts b/tests/reboot/std/presence/presence_tests.ts index 8a69e1e1..9558cb31 100644 --- a/tests/reboot/std/presence/presence_tests.ts +++ b/tests/reboot/std/presence/presence_tests.ts @@ -1,9 +1,26 @@ -import { test } from "node:test"; -import { Application, Reboot } from "@reboot-dev/reboot"; -import presence, { Presence } from "@reboot-dev/reboot-std/presence/v1"; +import { + Application, + Auth, + ReaderContext, + Reboot, + TokenVerifier, + allow, +} from "@reboot-dev/reboot"; +import { errors_pb } from "@reboot-dev/reboot-api"; import { MousePosition } from "@reboot-dev/reboot-std/presence/mouse_tracker/v1"; import { Subscriber } from "@reboot-dev/reboot-std/presence/subscriber/v1"; +import { Presence, presenceLibrary } from "@reboot-dev/reboot-std/presence/v1"; import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +class EmptyTokenVerifier extends TokenVerifier { + async verifyToken( + context: ReaderContext, + token?: string + ): Promise { + return new Auth({ userId: token || "default-user" }); + } +} test("Use Presence Servicers", async (t) => { // These tests simply verify that we are properly exporting the Presence servicers @@ -11,63 +28,145 @@ test("Use Presence Servicers", async (t) => { let rbt: Reboot; - t.before(async () => { + t.beforeEach(async () => { rbt = new Reboot(); await rbt.start(); - await rbt.up(new Application({ servicers: presence.servicers() })); }); - t.after(async () => { + t.afterEach(async () => { await rbt.stop(); }); await t.test("Create Subscriber", async (t) => { + await rbt.up( + new Application({ + libraries: [presenceLibrary()], + tokenVerifier: new EmptyTokenVerifier(), + }) + ); + const context = rbt.createExternalContext("test"); const subscriber = Subscriber.ref("test-id"); - await t.test("create a subscriber without throwing", async () => { - await subscriber.create(context); - }); + // Should not error. + await subscriber.create(context); }); await t.test("Subscribe", async (t) => { + await rbt.up( + new Application({ + libraries: [presenceLibrary()], + tokenVerifier: new EmptyTokenVerifier(), + }) + ); + const context = rbt.createExternalContext("test"); const subscriber = Subscriber.ref("test"); const presence = Presence.ref("presence-test-id"); - await t.test("create a subscriber without throwing", async () => { - await subscriber.create(context); - }); + // Should not error. + await subscriber.create(context); - await t.test( - "subsequent call to subscribe responds with the proper error", - async () => { - try { - await presence.subscribe(context, { - subscriberId: subscriber.stateId, - }); - - assert(false); - } catch (error) { - assert(error.code === 9); - assert(error.name === "PresenceSubscribeAborted"); - } - } - ); + // Subsequent call should error. + try { + await presence.subscribe(context, { + subscriberId: subscriber.stateId, + }); + + assert(false); + } catch (e) { + assert(e instanceof Presence.SubscribeAborted); + assert(e.code === 9); + assert(e.error instanceof errors_pb.FailedPrecondition); + } }); await t.test("Update Mouse Position", async (t) => { + await rbt.up( + new Application({ + libraries: [presenceLibrary()], + tokenVerifier: new EmptyTokenVerifier(), + }) + ); + const context = rbt.createExternalContext("test"); const mousePosition = MousePosition.ref("mouse-position-test-id"); - await t.test("update mouse position without throwing", async () => { - await mousePosition.update(context, { + // Should not error. + await mousePosition.update(context, { + left: 1, + top: 2, + }); + }); + + await t.test("Fail Authorization If No Valid Token", async (t) => { + await rbt.up( + new Application({ + libraries: [presenceLibrary()], + }) + ); + + // Assert that Subscriber calls cannot be made. + try { + let context = rbt.createExternalContext("test-1"); + await Subscriber.ref("test-id").create(context); + assert(false); + } catch (e) { + assert(e instanceof Subscriber.CreateAborted); + assert(e.code === 7); + assert(e.error instanceof errors_pb.PermissionDenied); + } + + // Assert that Presence calls cannot be made. + try { + let context = rbt.createExternalContext("test-2"); + await Presence.ref("test-id").subscribe(context, { + subscriberId: "test-id", + }); + assert(false); + } catch (e) { + assert(e instanceof Presence.SubscribeAborted); + assert(e.code === 7); + assert(e.error instanceof errors_pb.PermissionDenied); + } + + // Assert that MousePosition calls cannot be made. + try { + let context = rbt.createExternalContext("test-3"); + await MousePosition.ref("mouse-position-test-id").update(context, { left: 1, top: 2, }); - }); + assert(false); + } catch (e) { + assert(e instanceof MousePosition.UpdateAborted); + assert(e.code === 7); + assert(e.error instanceof errors_pb.PermissionDenied); + } }); + + await t.test( + "Override default authorizer with allow AuthorizerRule", + async (t) => { + await rbt.up( + new Application({ + libraries: [presenceLibrary({ authorizer: allow() })], + }) + ); + + const context = rbt.createExternalContext("test"); + + // Should not error. + await Subscriber.ref("test-id").create(context); + + // Should not error. + await MousePosition.ref("mouse-position-test-id").update(context, { + left: 1, + top: 2, + }); + } + ); });