Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12,274 changes: 7,372 additions & 4,902 deletions OPENAPI_DOC.yml

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions shard.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ shards:
git: https://github.com/spider-gazelle/active-model.git
version: 4.5.0

ameba:
git: https://github.com/crystal-ameba/ameba.git
version: 1.7.0

awscr-s3:
git: https://github.com/taylorfinnell/awscr-s3.git
version: 0.10.0
Expand Down Expand Up @@ -191,7 +195,7 @@ shards:

place_calendar:
git: https://github.com/placeos/calendar.git
version: 4.30.0
version: 4.30.1

placeos-compiler:
git: https://github.com/placeos/compiler.git
Expand All @@ -217,9 +221,9 @@ shards:
git: https://github.com/place-labs/log-backend.git
version: 0.13.0

placeos-models:
placeos-models: # Overridden
git: https://github.com/placeos/models.git
version: 9.118.1
version: 9.118.1+git.commit.e21d7904b1d9fe12b7852e9e835a50489647e5f6

placeos-resource:
git: https://github.com/place-labs/resource.git
Expand Down
8 changes: 4 additions & 4 deletions shard.override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ dependencies:
github: place-labs/ulid
branch: master

# uncomment to test different model branches
# placeos-models:
# github: placeos/models
# branch: add-signage-groups
# PPT-526: build against the multi-tenancy data layer until models #332 merges
placeos-models:
github: placeos/models
branch: PPT-526-partner-client
293 changes: 293 additions & 0 deletions spec/controllers/tenancy_spec.cr

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions spec/helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ def clear_tables
{% end %}
).get
{% end %}

# Tenancy rows go last: the estate tables above RESTRICT on organisations.
PlaceOS::Model::Edge.clear
PlaceOS::Model::Broker.clear
PlaceOS::Model::Grant.clear
PlaceOS::Model::Organisation.clear
PlaceOS::Model::Partner.clear
end

def random_name
Expand Down
3 changes: 2 additions & 1 deletion spec/migration/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ WORKDIR /app
COPY . /app

# NOTE:: add `--branch my-new-model-branch` if you want to test a particular branch
RUN git clone https://github.com/PlaceOS/models
# PPT-526: the tenancy migrations live on this branch until models #332 merges
RUN git clone --branch PPT-526-partner-client https://github.com/PlaceOS/models
RUN mv ./models/migration/db ./db

ENV PATH /app/bin:$PATH
Expand Down
146 changes: 146 additions & 0 deletions spec/spec_helpers/tenancy.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
require "placeos-models/spec/generator"

module PlaceOS::Api::Spec::Tenancy
# One customer organisation with a domain, three users and a small estate
record Org,
organisation : Model::Organisation,
authority : Model::Authority,
admin : Model::User,
support : Model::User,
user : Model::User,
org_zone : Model::Zone,
building : Model::Zone,
system : Model::ControlSystem,
mod : Model::Module

# The shape of a shared cluster: PlaceOS staff on localhost (cluster reach),
# NTT as a partner with its own staff organisation and one client (Acadian),
# UCLA as a direct customer, and an unowned zone tree.
record Fixture,
management : Model::Partner,
placeos : Org,
ntt : Model::Partner,
ntt_staff : Org,
acadian : Org,
ucla : Org,
unowned_zone : Model::Zone,
unowned_system : Model::ControlSystem

def self.build : Fixture
management = Model::Generator.partner(management: true).save!
ntt = Model::Generator.partner.save!

placeos = org("localhost", Model::Generator.organisation(partner: management, partner_staff: true).save!)
ntt_staff = org("ntt.test", Model::Generator.organisation(partner: ntt, partner_staff: true).save!)
acadian = org("acadian.test", Model::Generator.organisation(partner: ntt).save!)
ucla = org("ucla.test", Model::Generator.organisation.save!)

unowned_zone = Model::Generator.zone.tap { |z| z.name = "Unowned #{random_name}"; z.tags = Set{"org"} }.save!
unowned_system = Model::Generator.control_system.tap do |sys|
sys.name = "Unowned room #{random_name}"
sys.zones = [unowned_zone.id.as(String)]
end.save!

Fixture.new(management, placeos, ntt, ntt_staff, acadian, ucla, unowned_zone, unowned_system)
end

def self.org(domain : String, organisation : Model::Organisation) : Org
authority = Model::Authority.find_by_domain(domain) || Model::Generator.authority(domain)
authority.domain = domain
authority.organisation_id = organisation.id
authority.save!

org_zone = Model::Generator.zone.tap do |z|
z.name = "ORG #{organisation.name}"
z.tags = Set{"org"}
z.organisation_id = organisation.id
end.save!
authority.config_will_change!
authority.config["org_zone"] = JSON::Any.new(org_zone.id.as(String))
authority.save!

building = Model::Generator.zone.tap do |z|
z.name = "Building #{organisation.name}"
z.tags = Set{"building"}
z.parent_id = org_zone.id
z.organisation_id = organisation.id
end.save!

system = Model::Generator.control_system.tap do |sys|
sys.name = "Room #{organisation.name}"
sys.zones = [org_zone.id.as(String), building.id.as(String)]
sys.organisation_id = organisation.id
end.save!

mod = Model::Generator.module(control_system: system).tap(&.organisation_id = organisation.id).save!
system.modules = [mod.id.as(String)]
system.save!

Org.new(
organisation: organisation,
authority: authority,
admin: Model::Generator.user(authority, admin: true).save!,
support: Model::Generator.user(authority, support: true).save!,
user: Model::Generator.user(authority).save!,
org_zone: org_zone,
building: building,
system: system,
mod: mod,
)
end

# Removes everything a fixture and its example created. The suite only
# clears tables once, so each example must leave the database as it found
# it: other specs list zones and users without a page size.
def self.teardown(f : Fixture)
none = [] of ::PgORM::Value
Model::Module.where("organisation_id IS NOT NULL", none).to_a.each { |row| row.destroy rescue nil }
Model::ControlSystem.where("organisation_id IS NOT NULL", none).to_a.each { |row| row.destroy rescue nil }
Model::Trigger.where("organisation_id IS NOT NULL OR name LIKE 'Template %'", none).to_a.each { |row| row.destroy rescue nil }
Model::Edge.where("organisation_id IS NOT NULL", none).to_a.each { |row| row.destroy rescue nil }
Model::Zone.where("organisation_id IS NOT NULL", none).to_a
.sort_by { |zone| zone.parent_id.presence ? 0 : 1 }
.each { |row| row.destroy rescue nil }
[f.unowned_system, f.unowned_zone].each { |row| row.destroy rescue nil }

Model::Grant.clear
# fixture domains and anything an example created under them; localhost stays
Model::Authority.where("domain LIKE '%.test'", none).to_a.each { |row| row.destroy rescue nil }
[f.placeos.user, f.placeos.support, f.placeos.admin].each { |row| row.destroy rescue nil }
localhost = Model::Authority.find?(f.placeos.authority.id.as(String))
if localhost
localhost.organisation_id = nil
localhost.save!
end
Model::Organisation.clear
Model::Partner.clear
end

# Bearer + Host headers for a user on their own domain
def self.headers(user : Model::User) : HTTP::Headers
authority = user.authority.as(Model::Authority)
permissions = case {user.support, user.sys_admin}
when {true, true} then Model::UserJWT::Permissions::AdminSupport
when {true, false} then Model::UserJWT::Permissions::Support
when {false, true} then Model::UserJWT::Permissions::Admin
else Model::UserJWT::Permissions::User
end
jwt = Model::UserJWT.new(
iss: Model::UserJWT::ISSUER,
iat: 5.minutes.ago,
exp: 1.hour.from_now,
domain: authority.domain,
id: user.id.as(String),
user: Model::UserJWT::Metadata.new(name: user.name.as(String), email: user.email.to_s, permissions: permissions),
)
HTTP::Headers{
"Authorization" => "Bearer #{jwt.encode}",
"Content-Type" => "application/json",
"Host" => authority.domain,
}
end

def self.ids(body : String) : Array(String)
Array(Hash(String, JSON::Any)).from_json(body).map(&.["id"].to_s)
end
end
3 changes: 3 additions & 0 deletions src/constants.cr
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ module PlaceOS::Api
BUILD_TIME = {{ system("date -u").stringify }}
BUILD_COMMIT = {{ env("PLACE_COMMIT") || "DEV" }}

# PPT-526: refuse cross-organisation access (true) or only log what would be refused (false)
PLACE_TENANCY_ENFORCE = ENV["PLACE_TENANCY_ENFORCE"]?.try(&.downcase).in?("true", "1", "yes") || false

PLACE_DISPATCH_HOST = ENV["PLACE_DISPATCH_HOST"]? || "dispatch"
PLACE_DISPATCH_PORT = (ENV["PLACE_DISPATCH_PORT"]? || "3000").to_i

Expand Down
10 changes: 8 additions & 2 deletions src/placeos-rest-api/controllers/api_keys.cr
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ module PlaceOS::Api
def find_current_api_key(id : String)
Log.context.set(api_key: id)
# Find will raise a 404 (not found) if there is an error
@current_api_key = ::PlaceOS::Model::ApiKey.find!(id)
api_key = ::PlaceOS::Model::ApiKey.find!(id)
ensure_authority_reach!(api_key.authority_id, "api key")
@current_api_key = api_key
end

getter! current_api_key : ::PlaceOS::Model::ApiKey
Expand All @@ -33,9 +35,10 @@ module PlaceOS::Api
authority_id : String? = nil,
) : Array(::PlaceOS::Model::ApiKey::PublicResponse)
# PG full-text search (PPT-2644)
query = ::PlaceOS::Model::ApiKey.all
query = scope_authorities(::PlaceOS::Model::ApiKey.all)

if authority = authority_id
ensure_authority_reach!(authority, "domain")
query = query.where(authority_id: authority)
end

Expand All @@ -61,6 +64,9 @@ module PlaceOS::Api
# create a new API key
@[AC::Route::POST("/", body: :api_key, status_code: HTTP::Status::CREATED)]
def create(api_key : ::PlaceOS::Model::ApiKey) : ::PlaceOS::Model::ApiKey::PublicResponse
if user_id = api_key.user_id
ensure_authority_reach!(::PlaceOS::Model::User.find!(user_id).authority_id, "user")
end
raise Error::ModelValidation.new(api_key.errors) unless api_key.save
api_key.to_public_struct
end
Expand Down
3 changes: 3 additions & 0 deletions src/placeos-rest-api/controllers/application.cr
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ module PlaceOS::Api
# Helpers for defining scope checks on controller actions
include Utils::Scopes

# Helpers for organisation reach (PPT-526 multi-tenancy)
include Utils::Tenancy

# for converting comma seperated lists
# i.e. `"id-1,id-2,id-3"`
struct ConvertStringArray
Expand Down
8 changes: 6 additions & 2 deletions src/placeos-rest-api/controllers/authentications.cr
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ module PlaceOS::Api
def find_current_auth(id : String)
Log.context.set({{auth_type.id.underscore}}_id: id)
# Find will raise a 404 (not found) if there is an error
@current_auth = ::PlaceOS::Model::{{auth_type.id}}Authentication.find!(id)
auth = ::PlaceOS::Model::{{auth_type.id}}Authentication.find!(id)
ensure_authority_reach!(auth.authority_id, "authentication")
@current_auth = auth
end

getter! current_auth : ::PlaceOS::Model::{{auth_type.id}}Authentication
Expand All @@ -36,9 +38,10 @@ module PlaceOS::Api
authority_id : String? = nil,
) : Array(::PlaceOS::Model::{{auth_type.id}}Authentication)
# PG full-text search (PPT-2644)
query = ::PlaceOS::Model::{{auth_type.id}}Authentication.all
query = scope_authorities(::PlaceOS::Model::{{auth_type.id}}Authentication.all)

if authority = authority_id
ensure_authority_reach!(authority, "domain")
query = query.where(authority_id: authority)
end

Expand All @@ -64,6 +67,7 @@ module PlaceOS::Api
# creates a new authentication method
@[AC::Route::POST("/", body: :auth, status_code: HTTP::Status::CREATED)]
def create(auth : ::PlaceOS::Model::{{auth_type.id}}Authentication) : ::PlaceOS::Model::{{auth_type.id}}Authentication
ensure_authority_reach!(auth.authority_id, "domain")
raise Error::ModelValidation.new(auth.errors) unless auth.save
auth
end
Expand Down
2 changes: 2 additions & 0 deletions src/placeos-rest-api/controllers/brokers.cr
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ module PlaceOS::Api

before_action :check_admin, except: [:collection, :show]
before_action :check_support, only: [:collection, :show]
# brokers are cluster infrastructure
before_action :check_cluster_admin

###############################################################################################

Expand Down
1 change: 1 addition & 0 deletions src/placeos-rest-api/controllers/build_monitor.cr
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module PlaceOS::Api
before_action :can_write, only: [:cancel]

before_action :check_admin, only: [:cancel]
before_action :check_cluster_admin, only: [:cancel]

###############################################################################################

Expand Down
1 change: 1 addition & 0 deletions src/placeos-rest-api/controllers/cluster.cr
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module PlaceOS::Api
###############################################################################################

before_action :check_admin
before_action :check_cluster_admin
before_action :can_read, only: [:nodes, :show]
before_action :can_write, only: [:destroy]

Expand Down
32 changes: 26 additions & 6 deletions src/placeos-rest-api/controllers/domains.cr
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ module PlaceOS::Api
def find_current_domain(id : String)
Log.context.set(authority_id: id)
# Find will raise a 404 (not found) if there is an error
@current_domain = ::PlaceOS::Model::Authority.find!(id)
domain = ::PlaceOS::Model::Authority.find!(id)
ensure_reach!(domain)
@current_domain = domain
end

getter! current_domain : ::PlaceOS::Model::Authority
Expand All @@ -29,9 +31,13 @@ module PlaceOS::Api

# list the domains
@[AC::Route::GET("/")]
def index : Array(::PlaceOS::Model::Authority)
def index(
@[AC::Param::Info(description: "only rows owned by this organisation (must be within reach)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")]
organisation_id : UUID? = nil,
) : Array(::PlaceOS::Model::Authority)
# PG full-text search (PPT-2644)
paginate_search(::PlaceOS::Model::Authority.all, ::PlaceOS::Model::Authority.table_name)
query = narrow_organisation(scope_organisations(::PlaceOS::Model::Authority.all), organisation_id)
paginate_search(query, ::PlaceOS::Model::Authority.table_name)
end

# skip authentication for the lookup
Expand Down Expand Up @@ -59,16 +65,30 @@ module PlaceOS::Api
# udpate a domains details
@[AC::Route::PATCH("/:id", body: :domain)]
@[AC::Route::PUT("/:id", body: :domain)]
def update(domain : ::PlaceOS::Model::Authority) : ::PlaceOS::Model::Authority
def update(
domain : ::PlaceOS::Model::Authority,
@[AC::Param::Info(description: "move the domain to this organisation (cluster admins only)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")]
organisation_id : UUID? = nil,
) : ::PlaceOS::Model::Authority
current = current_domain
current.assign_attributes(domain)
if organisation_id
check_cluster_admin
current.organisation_id = organisation_id
end
raise Error::ModelValidation.new(current.errors) unless current.save
current
end

# add a new domain
# add a new domain. Organisation admins create domains in their own
# organisation; cluster admins name the organisation.
@[AC::Route::POST("/", body: :domain, status_code: HTTP::Status::CREATED)]
def create(domain : ::PlaceOS::Model::Authority) : ::PlaceOS::Model::Authority
def create(
domain : ::PlaceOS::Model::Authority,
@[AC::Param::Info(description: "the organisation that owns the new domain", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")]
organisation_id : UUID? = nil,
) : ::PlaceOS::Model::Authority
domain.organisation_id = organisation_for_new_row(organisation_id)
raise Error::ModelValidation.new(domain.errors) unless domain.save
domain
end
Expand Down
2 changes: 2 additions & 0 deletions src/placeos-rest-api/controllers/drivers.cr
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ module PlaceOS::Api
before_action :can_write, only: [:create, :update, :destroy, :remove]

before_action :check_admin, except: [:index, :show, :readme]
# drivers are shared by every organisation; only cluster admins change them
before_action :check_cluster_admin, except: [:index, :show, :readme]

# Restrict driver listing and readme access to admin/support. These
# endpoints expose internal infrastructure detail (driver inventory, driver
Expand Down
Loading
Loading