diff --git a/.eslintignore b/.eslintignore index 537b7fae..d74d6023 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,3 +4,5 @@ node_modules dist # don't lint nyc coverage output coverage +src/migration/ +.eslintrc.js diff --git a/.eslintrc.js b/.eslintrc.js index d3256d24..6c46aa22 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,7 +23,7 @@ module.exports = { "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-floating-promises": "warn", "max-lines-per-function": ["error", 80], - "max-lines-per-function": ["warn", 40], + "max-lines-per-function": ["warn", 55], }, overrides: [ { diff --git a/ormconfig.js b/ormconfig.js new file mode 100644 index 00000000..7c09f177 --- /dev/null +++ b/ormconfig.js @@ -0,0 +1,15 @@ +module.exports = { + "type": "postgres", + "host": process.env.DATABASE_HOSTNAME || "host.docker.internal", + "port": parseInt(process.env.DATABASE_PORT, 10) || 5433, + "username": process.env.DATABASE_USERNAME || "os2iot", + "password": process.env.DATABASE_PASSWORD || "toi2so", + "database": "os2iot", + "synchronize": false, + "logging": false, + "entities": ["src/entities/*.ts"], + "migrations": ["src/migration/*.ts"], + "cli": { + "migrationsDir": "src/migration" + } +} diff --git a/ormconfig.json b/ormconfig.json deleted file mode 100644 index 43477c63..00000000 --- a/ormconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "type": "postgres", - "host": "host.docker.internal", - "port": 5433, - "username": "os2iot", - "password": "toi2so", - "database": "os2iot", - "synchronize": true, - "logging": true, - "autoLoadEntities": true -} diff --git a/package-lock.json b/package-lock.json index fd088b89..6a1b55be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1715,9 +1715,9 @@ } }, "@nestjs/mapped-types": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-0.1.1.tgz", - "integrity": "sha512-FROYmmZ2F+tLJP/aHasPMX40iUHQPtEAzOAcfAp21baebN5iLUrdyTuphoXjIqubfPFSwtnAGpVm9kLJjQ//ig==" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-0.4.1.tgz", + "integrity": "sha512-JXrw2LMangSU3vnaXWXVX47GRG1FbbNh4aVBbidDjxT3zlghsoNQY6qyWtT001MCl8lJGo8I6i6+DurBRRxl/Q==" }, "@nestjs/passport": { "version": "7.1.5", @@ -1798,13 +1798,20 @@ } }, "@nestjs/swagger": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-4.7.6.tgz", - "integrity": "sha512-9cj/rAqqnUxHnGqgO9GE1HU5x0BxQmWgWgenLh/cwdW4dFys7JI3xN2RWaEtZCVobvKYUWBPNvXnoduYI/1OKg==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-4.8.2.tgz", + "integrity": "sha512-RSUwcVxrzXF7/b/IZ5lXnYHJ6jIGS9wWRTJKIt1kIaCNWT+0wRfTlAyhQkzs2g35/PTXJEcdIwwY7mBO/bwHzw==", "requires": { - "@nestjs/mapped-types": "0.1.1", - "lodash": "4.17.20", + "@nestjs/mapped-types": "0.4.1", + "lodash": "4.17.21", "path-to-regexp": "3.2.0" + }, + "dependencies": { + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + } } }, "@nestjs/testing": { @@ -2387,9 +2394,10 @@ "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==" }, "@types/validator": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.0.0.tgz", - "integrity": "sha512-WAy5txG7aFX8Vw3sloEKp5p/t/Xt8jD3GRD9DacnFv6Vo8ubudAsRTXgxpQwU0mpzY/H8U4db3roDuCMjShBmw==" + "version": "13.7.1", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.1.tgz", + "integrity": "sha512-I6OUIZ5cYRk5lp14xSOAiXjWrfVoMZVjDuevBYgQDYzZIjsf2CAISpEcXOkFAtpAHbmWIDLcZObejqny/9xq5Q==", + "dev": true }, "@types/webpack": { "version": "4.41.25", @@ -3547,9 +3555,9 @@ "dev": true }, "class-transformer": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.3.1.tgz", - "integrity": "sha512-cKFwohpJbuMovS8xVLmn8N2AUbAuc8pVo4zEfsUVo8qgECOogns1WVk/FkOZoxhOPTyTYFckuoH+13FO+MQ8GA==" + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "class-utils": { "version": "0.3.6", @@ -3575,14 +3583,12 @@ } }, "class-validator": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.12.2.tgz", - "integrity": "sha512-TDzPzp8BmpsbPhQpccB3jMUE/3pK0TyqamrK0kcx+ZeFytMA+O6q87JZZGObHHnoo9GM8vl/JppIyKWeEA/EVw==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.2.tgz", + "integrity": "sha512-yBUcQy07FPlGzUjoLuUfIOXzgynnQPPruyK1Ge2B74k9ROwnle1E+NxLWnUv5OLU8hA/qL5leAE9XnXq3byaBw==", "requires": { - "@types/validator": "13.0.0", - "google-libphonenumber": "^3.2.8", - "tslib": ">=1.9.0", - "validator": "13.0.0" + "libphonenumber-js": "^1.9.43", + "validator": "^13.7.0" } }, "cli-cursor": { @@ -5619,11 +5625,6 @@ } } }, - "google-libphonenumber": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.10.tgz", - "integrity": "sha512-TsckE9O8QgqaIeaOXPjcJa4/kX3BzFdO1oCbMfmUpRZckml4xJhjJVxaT9Mdt/VrZZkT9lX44eHAEWfJK1tHtw==" - }, "graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", @@ -9230,6 +9231,11 @@ "type-check": "~0.4.0" } }, + "libphonenumber-js": { + "version": "1.9.44", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.44.tgz", + "integrity": "sha512-zhw8nUMJuQf7jG1dZfEOKKOS6M3QYIv3HnvB/vGohNd0QfxIQcObH3a6Y6s350H+9xgBeOXClOJkS0hJ0yvS3g==" + }, "lines-and-columns": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", @@ -10324,6 +10330,15 @@ "pause": "0.0.1" } }, + "passport-headerapikey": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/passport-headerapikey/-/passport-headerapikey-1.2.2.tgz", + "integrity": "sha512-4BvVJRrWsNJPrd3UoZfcnnl4zvUWYKEtfYkoDsaOKBsrWHYmzTApCjs7qUbncOLexE9ul0IRiYBFfBG0y9IVQA==", + "requires": { + "lodash": "^4.17.15", + "passport-strategy": "^1.0.0" + } + }, "passport-jwt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz", @@ -12323,11 +12338,6 @@ "tsconfig-paths": "^3.4.0" } }, - "tslib": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz", - "integrity": "sha512-lTqkx847PI7xEDYJntxZH89L2/aXInsyF2luSafe/+0fHOMjlBNXdH6th7f70qxLDhul7KZK0zC8V5ZIyHl0/g==" - }, "tsutils": { "version": "3.17.1", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz", @@ -12643,9 +12653,9 @@ } }, "validator": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.0.0.tgz", - "integrity": "sha512-anYx5fURbgF04lQV18nEQWZ/3wHGnxiKdG4aL8J+jEDsm98n/sU/bey+tYk6tnGJzm7ioh5FoqrAiQ6m03IgaA==" + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", + "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==" }, "vary": { "version": "1.1.2", diff --git a/package.json b/package.json index b379e0b2..d7152e85 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,13 @@ "scripts": { "prebuild": "rimraf dist", "build": "nest build", + "generate-migration": "npm run typeorm migration:generate -- -n", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "prestart": "npm run run-migrations", + "prestart:debug": "npm run run-migrations", + "prestart:dev": "npm run run-migrations", + "prestart:prod": "npm run run-migrations", + "run-migrations": "npm run typeorm migration:run", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", @@ -19,7 +25,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./jest-e2e.js --detectOpenHandles --colors", - "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config ./ormconfig.json", + "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config ./ormconfig.js", "typeorm-e2e": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config ./ormconfig-e2e.json" }, "dependencies": { @@ -30,7 +36,7 @@ "@nestjs/passport": "^7.1.5", "@nestjs/platform-express": "^7.6.1", "@nestjs/schedule": "^0.4.1", - "@nestjs/swagger": "^4.7.6", + "@nestjs/swagger": "^4.8.2", "@nestjs/typeorm": "^7.1.5", "@types/bcryptjs": "^2.4.2", "@types/geojson": "^7946.0.7", @@ -42,8 +48,8 @@ "axios-cache-adapter": "^2.5.0", "bcryptjs": "^2.4.3", "bluebird": "^3.7.2", - "class-transformer": "^0.3.1", - "class-validator": "^0.12.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.13.2", "compression": "^1.7.4", "cookie-parser": "^1.4.5", "kafkajs": "^1.15.0", @@ -52,6 +58,7 @@ "nestjs-pino": "^1.3.0", "njwt": "^1.0.0", "passport": "^0.4.1", + "passport-headerapikey": "^1.2.2", "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", "passport-saml": "^1.3.5", @@ -78,6 +85,7 @@ "@types/passport-jwt": "^3.0.3", "@types/passport-local": "^1.0.33", "@types/supertest": "^2.0.10", + "@types/validator": "^13.7.1", "@typescript-eslint/eslint-plugin": "^4.10.0", "@typescript-eslint/parser": "^4.10.0", "eslint": "^7.15.0", diff --git a/src/auth/api-key-auth.guard.ts b/src/auth/api-key-auth.guard.ts new file mode 100644 index 00000000..c12890d0 --- /dev/null +++ b/src/auth/api-key-auth.guard.ts @@ -0,0 +1,6 @@ +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { ApiKeyStrategyName } from "./constants"; + +@Injectable() +export class ApiKeyAuthGuard extends AuthGuard(ApiKeyStrategyName) {} diff --git a/src/auth/api-key.strategy.ts b/src/auth/api-key.strategy.ts new file mode 100644 index 00000000..a7593ffa --- /dev/null +++ b/src/auth/api-key.strategy.ts @@ -0,0 +1,56 @@ +import { AuthenticatedUser } from "@dto/internal/authenticated-user"; +import { ErrorCodes } from "@enum/error-codes.enum"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { AuthService } from "@services/user-management/auth.service"; +import { PermissionService } from "@services/user-management/permission.service"; +import { HeaderAPIKeyStrategy } from "passport-headerapikey"; +import { ApiKeyHeader, ApiKeyStrategyName, HeaderApiVerifiedCallback } from "./constants"; + +const passReqToCallback = false; + +@Injectable() +export class ApiKeyStrategy extends PassportStrategy( + HeaderAPIKeyStrategy, + ApiKeyStrategyName +) { + constructor( + private authService: AuthService, + private permissionService: PermissionService + ) { + super( + { + header: ApiKeyHeader, + prefix: "", + }, + passReqToCallback + ); + } + + async validate( + apiKey: string, + _done: HeaderApiVerifiedCallback + ): Promise { + const apiKeyDb = await this.authService.validateApiKey(apiKey); + if (!apiKeyDb) { + throw new UnauthorizedException(ErrorCodes.ApiKeyAuthFailed); + } + + // Get the permissions and the UserID from the API Key instead of the user + const permissions = await this.permissionService.findPermissionGroupedByLevelForApiKey( + apiKeyDb.id + ); + + // const permissions = dbApiKey.permissions as Permission[]; + const userId = apiKeyDb.systemUser.id; + + // Set the permissions and the userId on the returned user + const user: AuthenticatedUser = { + userId, + username: apiKeyDb.systemUser.name, + permissions, + }; + + return user; + } +} diff --git a/src/auth/compose-auth.guard.ts b/src/auth/compose-auth.guard.ts new file mode 100644 index 00000000..6548d74a --- /dev/null +++ b/src/auth/compose-auth.guard.ts @@ -0,0 +1,11 @@ +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { ApiKeyStrategyName, JwtStrategyName } from "./constants"; + +@Injectable() +/** + * Let authentication go through a chain of strategies. The first to succeed, redirect, or error will halt the chain + * If a strategy fails (not errors! Ex. JWT token wasn't valid), then authentication proceeds to the next strategy. + * Source: https://docs.nestjs.com/security/authentication#extending-guards + */ +export class ComposeAuthGuard extends AuthGuard([JwtStrategyName, ApiKeyStrategyName]) {} diff --git a/src/auth/constants.ts b/src/auth/constants.ts new file mode 100644 index 00000000..cf503f0f --- /dev/null +++ b/src/auth/constants.ts @@ -0,0 +1,13 @@ +import { AuthenticatedUser } from "@dto/internal/authenticated-user"; + +export type HeaderApiVerifiedCallback = ( + err: Error | null, + user?: AuthenticatedUser, + info?: Record +) => void; + +export const ApiKeyStrategyName = "api-key"; +export const ApiKeyHeader = "X-API-KEY"; +export const LocalStrategyName = "local"; +export const JwtStrategyName = "jwt"; +export const RolesMetaData = "roles"; diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts index 2e81dba6..1c700ecc 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/auth/jwt-auth.guard.ts @@ -1,5 +1,6 @@ import { Injectable } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; +import { JwtStrategyName } from "./constants"; @Injectable() -export class JwtAuthGuard extends AuthGuard("jwt") {} +export class JwtAuthGuard extends AuthGuard(JwtStrategyName) {} diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 6c998b26..83a0cc8b 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -1,21 +1,22 @@ +import { AuthenticatedUser } from "@dto/internal/authenticated-user"; +import { JwtPayloadDto } from "@entities/dto/internal/jwt-payload.dto"; import { Injectable, Logger, UnauthorizedException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; -import { ExtractJwt, Strategy } from "passport-jwt"; - -import { AuthenticatedUser } from "@dto/internal/authenticated-user"; -import { JwtPayloadDto } from "@entities/dto/internal/jwt-payload.dto"; import { PermissionService } from "@services/user-management/permission.service"; import { UserService } from "@services/user-management/user.service"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import { JwtStrategyName } from "./constants"; @Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { +export class JwtStrategy extends PassportStrategy(Strategy, JwtStrategyName) { constructor( private permissionService: PermissionService, private userService: UserService, private configService: ConfigService ) { super({ + // Configure the strategy to look for the JWT token in the Authorization header jwtFromRequest: ExtractJwt.fromExtractors([ ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter("secret_token"), diff --git a/src/auth/local-auth.guard.ts b/src/auth/local-auth.guard.ts index e2e4c051..e102d6ca 100644 --- a/src/auth/local-auth.guard.ts +++ b/src/auth/local-auth.guard.ts @@ -1,5 +1,6 @@ import { Injectable } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; +import { LocalStrategyName } from "./constants"; @Injectable() -export class LocalAuthGuard extends AuthGuard("local") {} +export class LocalAuthGuard extends AuthGuard(LocalStrategyName) {} diff --git a/src/auth/local.strategy.ts b/src/auth/local.strategy.ts index 28b4bc46..be24368b 100644 --- a/src/auth/local.strategy.ts +++ b/src/auth/local.strategy.ts @@ -1,11 +1,11 @@ import { Injectable, UnauthorizedException } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; -import { Strategy } from "passport-local"; - import { AuthService } from "@services/user-management/auth.service"; +import { Strategy } from "passport-local"; +import { LocalStrategyName } from "./constants"; @Injectable() -export class LocalStrategy extends PassportStrategy(Strategy) { +export class LocalStrategy extends PassportStrategy(Strategy, LocalStrategyName) { constructor(private authService: AuthService) { super(); } diff --git a/src/auth/roles.decorator.ts b/src/auth/roles.decorator.ts index db72ccc1..bd4fe8de 100644 --- a/src/auth/roles.decorator.ts +++ b/src/auth/roles.decorator.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { SetMetadata } from "@nestjs/common"; - import { PermissionType } from "@enum/permission-type.enum"; +import { SetMetadata } from "@nestjs/common"; +import { RolesMetaData } from "./constants"; -export const Read = () => SetMetadata("roles", PermissionType.Read); -export const Write = () => SetMetadata("roles", PermissionType.Write); +export const Read = () => SetMetadata(RolesMetaData, PermissionType.Read); +export const Write = () => SetMetadata(RolesMetaData, PermissionType.Write); export const OrganizationAdmin = () => - SetMetadata("roles", PermissionType.OrganizationAdmin); -export const GlobalAdmin = () => SetMetadata("roles", PermissionType.GlobalAdmin); + SetMetadata(RolesMetaData, PermissionType.OrganizationAdmin); +export const GlobalAdmin = () => SetMetadata(RolesMetaData, PermissionType.GlobalAdmin); diff --git a/src/auth/roles.guard.ts b/src/auth/roles.guard.ts index c57cc48f..64c58745 100644 --- a/src/auth/roles.guard.ts +++ b/src/auth/roles.guard.ts @@ -1,8 +1,8 @@ -import { CanActivate, ExecutionContext, Injectable, Logger } from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; - import { AuthenticatedUser } from "@dto/internal/authenticated-user"; import { PermissionType } from "@enum/permission-type.enum"; +import { CanActivate, ExecutionContext, Injectable, Logger } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { RolesMetaData } from "./constants"; @Injectable() export class RolesGuard implements CanActivate { @@ -11,10 +11,20 @@ export class RolesGuard implements CanActivate { private readonly logger = new Logger(RolesGuard.name); canActivate(context: ExecutionContext): boolean { - const roleRequired = this.reflector.get("roles", context.getHandler()); + const roleRequiredMethod = this.reflector.get( + RolesMetaData, + context.getHandler() + ); + const roleRequiredClass = this.reflector.get( + RolesMetaData, + context.getClass() + ); + const roleRequired = roleRequiredMethod ?? roleRequiredClass; + if (!roleRequired) { return true; } + const request = context.switchToHttp().getRequest(); const user: AuthenticatedUser = request.user; this.logger.verbose( diff --git a/src/controllers/admin-controller/api-key-controller/api-key.controller.ts b/src/controllers/admin-controller/api-key-controller/api-key.controller.ts new file mode 100644 index 00000000..054779a5 --- /dev/null +++ b/src/controllers/admin-controller/api-key-controller/api-key.controller.ts @@ -0,0 +1,185 @@ +import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { OrganizationAdmin, Read } from "@auth/roles.decorator"; +import { RolesGuard } from "@auth/roles.guard"; +import { ApiKeyResponseDto } from "@dto/api-key/api-key-response.dto"; +import { CreateApiKeyDto } from "@dto/api-key/create-api-key.dto"; +import { ListAllApiKeysResponseDto } from "@dto/api-key/list-all-api-keys-response.dto"; +import { ListAllApiKeysDto } from "@dto/api-key/list-all-api-keys.dto"; +import { DeleteResponseDto } from "@dto/delete-application-response.dto"; +import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; +import { ApiKey } from "@entities/api-key.entity"; +import { ActionType } from "@entities/audit-log-entry"; +import { ErrorCodes } from "@enum/error-codes.enum"; +import { + checkIfUserHasAdminAccessToAllOrganizations, + checkIfUserHasAdminAccessToOrganization, +} from "@helpers/security-helper"; +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + NotFoundException, + Param, + ParseIntPipe, + Post, + Query, + Req, + UseGuards, + Put, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, + ApiBadRequestResponse, +} from "@nestjs/swagger"; +import { ApiKeyService } from "@services/api-key-management/api-key.service"; +import { AuditLog } from "@services/audit-log.service"; +import { OrganizationService } from "@services/user-management/organization.service"; +import { UpdateApiKeyDto } from "@dto/api-key/update-api-key.dto"; + +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +@OrganizationAdmin() +@Read() +@ApiForbiddenResponse() +@ApiUnauthorizedResponse() +@ApiTags("API Key Management") +@Controller("api-key") +export class ApiKeyController { + constructor( + private apiKeyService: ApiKeyService, + private organizationService: OrganizationService + ) {} + + @Post() + @ApiOperation({ summary: "Create new API key" }) + async createApiKey( + @Req() req: AuthenticatedRequest, + @Body() dto: CreateApiKeyDto + ): Promise { + await this.checkIfUserHasAccessToPermissions(req, dto.permissionIds); + + try { + const result = await this.apiKeyService.create(dto, req.user.userId); + + AuditLog.success( + ActionType.CREATE, + ApiKey.name, + req.user.userId, + result.id, + result.name + ); + return result; + } catch (err) { + AuditLog.fail(ActionType.CREATE, ApiKey.name, req.user.userId); + throw err; + } + } + + @Put(":id") + @ApiOperation({ summary: "Update API key" }) + @ApiBadRequestResponse() + async updateApiKey( + @Req() req: AuthenticatedRequest, + @Param("id", new ParseIntPipe()) id: number, + @Body() dto: UpdateApiKeyDto + ): Promise { + await this.checkIfUserHasAccessToPermissions(req, dto.permissionIds); + + try { + const result = await this.apiKeyService.update(id, dto, req.user.userId); + + AuditLog.success( + ActionType.UPDATE, + ApiKey.name, + req.user.userId, + result.id, + result.name + ); + return result; + } catch (err) { + AuditLog.fail(ActionType.UPDATE, ApiKey.name, req.user.userId, id); + throw err; + } + } + + @Delete(":id") + @ApiOperation({ summary: "Delete an API key entity" }) + async deleteApiKey( + @Req() req: AuthenticatedRequest, + @Param("id", new ParseIntPipe()) id: number + ): Promise { + try { + await this.checkIfUserHasAccessToApiKey(req, id); + + const result = await this.apiKeyService.delete(id); + + AuditLog.success(ActionType.DELETE, ApiKey.name, req.user.userId, id); + return result; + } catch (err) { + AuditLog.fail(ActionType.DELETE, ApiKey.name, req.user.userId, id); + throw err; + } + } + + @Get() + @ApiOperation({ summary: "Get list of all API keys in organization" }) + getAllApiKeysInOrganization( + @Req() req: AuthenticatedRequest, + @Query() query: ListAllApiKeysDto + ): Promise { + checkIfUserHasAdminAccessToOrganization(req, query.organizationId); + + try { + return this.apiKeyService.findAllByOrganizationId(query); + } catch (err) { + throw new NotFoundException(ErrorCodes.IdDoesNotExists); + } + } + + @Get(":id") + @ApiOperation({ summary: "Get API key" }) + @ApiNotFoundResponse() + async getOneApiKey( + @Req() req: AuthenticatedRequest, + @Param("id", new ParseIntPipe()) id: number + ): Promise { + await this.checkIfUserHasAccessToApiKey(req, id); + + try { + return await this.apiKeyService.findOneByIdWithPermissions(id); + } catch (err) { + throw new NotFoundException(ErrorCodes.IdDoesNotExists); + } + } + + private async checkIfUserHasAccessToApiKey(req: AuthenticatedRequest, id: number) { + const apiKey = await this.apiKeyService.findOneByIdWithRelations(id); + await this.checkIfUserHasAccessToPermissions( + req, + apiKey.permissions.map(x => x.id) + ); + } + + private async checkIfUserHasAccessToPermissions( + req: AuthenticatedRequest, + permissionIds: number[] + ) { + if (!permissionIds?.length) throw new ForbiddenException(); + + const apiKeyOrganizations = await this.organizationService.findByPermissionIds( + permissionIds + ); + + checkIfUserHasAdminAccessToAllOrganizations( + req, + apiKeyOrganizations.map(x => x.id) + ); + } +} diff --git a/src/controllers/admin-controller/application.controller.ts b/src/controllers/admin-controller/application.controller.ts index cc155d6c..0c40e193 100644 --- a/src/controllers/admin-controller/application.controller.ts +++ b/src/controllers/admin-controller/application.controller.ts @@ -27,7 +27,6 @@ import { } from "@nestjs/swagger"; import { ApiResponse } from "@nestjs/swagger"; -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; import { Read, Write } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { CreateApplicationDto } from "@dto/create-application.dto"; @@ -49,10 +48,11 @@ import { AuditLog } from "@services/audit-log.service"; import { ActionType } from "@entities/audit-log-entry"; import { ListAllEntitiesDto } from "@dto/list-all-entities.dto"; import { ListAllIoTDevicesResponseDto } from "@dto/list-all-iot-devices-response.dto"; +import { ComposeAuthGuard } from "@auth/compose-auth.guard"; @ApiTags("Application") @Controller("application") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() diff --git a/src/controllers/admin-controller/chirpstack/chirpstack-gateway.controller.ts b/src/controllers/admin-controller/chirpstack/chirpstack-gateway.controller.ts index eb918791..d378b253 100644 --- a/src/controllers/admin-controller/chirpstack/chirpstack-gateway.controller.ts +++ b/src/controllers/admin-controller/chirpstack/chirpstack-gateway.controller.ts @@ -20,10 +20,8 @@ import { ApiTags, } from "@nestjs/swagger"; -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; import { Read, Write } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; -import { ChirpstackPaginatedListDto } from "@dto/chirpstack/chirpstack-paginated-list.dto"; import { ChirpstackResponseStatus } from "@dto/chirpstack/chirpstack-response.dto"; import { CreateGatewayDto } from "@dto/chirpstack/create-gateway.dto"; import { ListAllGatewaysResponseDto } from "@dto/chirpstack/list-all-gateways.dto"; @@ -36,12 +34,12 @@ import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; import { AuditLog } from "@services/audit-log.service"; import { ActionType } from "@entities/audit-log-entry"; import { ChirpstackGetAll } from "@dto/chirpstack/chirpstack-get-all.dto"; +import { ComposeAuthGuard } from "@auth/compose-auth.guard"; @ApiTags("Chirpstack") @Controller("chirpstack/gateway") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() -@Write() export class ChirpstackGatewayController { constructor(private chirpstackGatewayService: ChirpstackGatewayService) {} @@ -49,6 +47,7 @@ export class ChirpstackGatewayController { @ApiProduces("application/json") @ApiOperation({ summary: "Create a new Chirpstack Gateway" }) @ApiBadRequestResponse() + @Write() async create( @Req() req: AuthenticatedRequest, @Body() dto: CreateGatewayDto @@ -98,6 +97,7 @@ export class ChirpstackGatewayController { @Get(":gatewayId") @ApiProduces("application/json") @ApiOperation({ summary: "List all Chirpstack gateways" }) + @Read() async getOne( @Param("gatewayId") gatewayId: string ): Promise { @@ -116,6 +116,7 @@ export class ChirpstackGatewayController { @ApiProduces("application/json") @ApiOperation({ summary: "Create a new Chirpstack Gateway" }) @ApiBadRequestResponse() + @Write() async update( @Req() req: AuthenticatedRequest, @Param("gatewayId") gatewayId: string, @@ -151,6 +152,7 @@ export class ChirpstackGatewayController { } @Delete(":gatewayId") + @Write() async delete( @Req() req: AuthenticatedRequest, @Param("gatewayId") gatewayId: string diff --git a/src/controllers/admin-controller/chirpstack/device-profile.controller.ts b/src/controllers/admin-controller/chirpstack/device-profile.controller.ts index 0908db7b..6d77d983 100644 --- a/src/controllers/admin-controller/chirpstack/device-profile.controller.ts +++ b/src/controllers/admin-controller/chirpstack/device-profile.controller.ts @@ -24,7 +24,6 @@ import { ApiTags, } from "@nestjs/swagger"; -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; import { Read, Write } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { CreateChirpstackProfileResponseDto } from "@dto/chirpstack/create-chirpstack-profile-response.dto"; @@ -38,12 +37,12 @@ import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; import { checkIfUserHasWriteAccessToOrganization } from "@helpers/security-helper"; import { AuditLog } from "@services/audit-log.service"; import { ActionType } from "@entities/audit-log-entry"; +import { ComposeAuthGuard } from "@auth/compose-auth.guard"; @ApiTags("Chirpstack") @Controller("chirpstack/device-profiles") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() -@Write() export class DeviceProfileController { constructor(private deviceProfileService: DeviceProfileService) {} diff --git a/src/controllers/admin-controller/chirpstack/service-profile.controller.ts b/src/controllers/admin-controller/chirpstack/service-profile.controller.ts index a9b96cdc..03d75493 100644 --- a/src/controllers/admin-controller/chirpstack/service-profile.controller.ts +++ b/src/controllers/admin-controller/chirpstack/service-profile.controller.ts @@ -1,3 +1,4 @@ +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; import { BadRequestException, Body, @@ -24,7 +25,6 @@ import { ApiTags, } from "@nestjs/swagger"; -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; import { Read, Write } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { CreateChirpstackProfileResponseDto } from "@dto/chirpstack/create-chirpstack-profile-response.dto"; @@ -40,9 +40,8 @@ import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; @ApiTags("Chirpstack") @Controller("chirpstack/service-profiles") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() -@Write() export class ServiceProfileController { constructor(private serviceProfileService: ServiceProfileService) {} private readonly logger = new Logger(ServiceProfileController.name); diff --git a/src/controllers/admin-controller/data-target.controller.ts b/src/controllers/admin-controller/data-target.controller.ts index 29b86685..8a380a43 100644 --- a/src/controllers/admin-controller/data-target.controller.ts +++ b/src/controllers/admin-controller/data-target.controller.ts @@ -23,7 +23,7 @@ import { ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; import { Read, Write } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { CreateDataTargetDto } from "@dto/create-data-target.dto"; @@ -44,7 +44,7 @@ import { ActionType } from "@entities/audit-log-entry"; @ApiTags("Data Target") @Controller("data-target") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() @@ -134,7 +134,7 @@ export class DataTargetController { const oldDataTarget = await this.dataTargetService.findOne(id); try { checkIfUserHasWriteAccessToApplication(req, oldDataTarget.application.id); - if (oldDataTarget.application.id != updateDto.applicationId) { + if (oldDataTarget.application.id !== updateDto.applicationId) { checkIfUserHasWriteAccessToApplication(req, updateDto.applicationId); } } catch (err) { @@ -164,7 +164,7 @@ export class DataTargetController { } @Delete(":id") - @ApiOperation({ summary: "Delete an existing IoT-Device" }) + @ApiOperation({ summary: "Delete an existing DataTarget" }) @ApiBadRequestResponse() @Write() async delete( @@ -183,7 +183,7 @@ export class DataTargetController { return new DeleteResponseDto(result.affected); } catch (err) { AuditLog.fail(ActionType.DELETE, DataTarget.name, req.user.userId, id); - if (err?.status == 403) { + if (err?.status === 403) { throw err; } throw new NotFoundException(err); diff --git a/src/controllers/admin-controller/device-model.controller.ts b/src/controllers/admin-controller/device-model.controller.ts index 8690385b..80368ec6 100644 --- a/src/controllers/admin-controller/device-model.controller.ts +++ b/src/controllers/admin-controller/device-model.controller.ts @@ -1,4 +1,4 @@ -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; import { Read } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { CreateDeviceModelDto } from "@dto/create-device-model.dto"; @@ -44,7 +44,7 @@ import { DeviceModelService } from "@services/device-management/device-model.ser @ApiTags("Device Model") @Controller("device-model") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() diff --git a/src/controllers/admin-controller/iot-device-payload-decoder-data-target-connection.controller.ts b/src/controllers/admin-controller/iot-device-payload-decoder-data-target-connection.controller.ts index 2dba6beb..455e703b 100644 --- a/src/controllers/admin-controller/iot-device-payload-decoder-data-target-connection.controller.ts +++ b/src/controllers/admin-controller/iot-device-payload-decoder-data-target-connection.controller.ts @@ -24,7 +24,7 @@ import { ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; import { Read, Write } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { CreateIoTDevicePayloadDecoderDataTargetConnectionDto } from "@dto/create-iot-device-payload-decoder-data-target-connection.dto"; @@ -44,7 +44,7 @@ import { ActionType } from "@entities/audit-log-entry"; @ApiTags("IoT-Device, PayloadDecoder and DataTarget Connection") @Controller("iot-device-payload-decoder-data-target-connection") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() diff --git a/src/controllers/admin-controller/iot-device-payload-decoder.controller.ts b/src/controllers/admin-controller/iot-device-payload-decoder.controller.ts index 4ed803a5..51400a96 100644 --- a/src/controllers/admin-controller/iot-device-payload-decoder.controller.ts +++ b/src/controllers/admin-controller/iot-device-payload-decoder.controller.ts @@ -16,7 +16,7 @@ import { ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; import { Read } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { IoTDeviceService } from "@services/device-management/iot-device.service"; @@ -29,7 +29,7 @@ import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; @ApiTags("IoT Device") @Controller("iot-device/minimalByPayloadDecoder") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() diff --git a/src/controllers/admin-controller/iot-device.controller.ts b/src/controllers/admin-controller/iot-device.controller.ts index 8bcb8206..d479fc33 100644 --- a/src/controllers/admin-controller/iot-device.controller.ts +++ b/src/controllers/admin-controller/iot-device.controller.ts @@ -24,7 +24,7 @@ import { ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from "@auth/compose-auth.guard"; import { Read } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { CreateIoTDeviceDto } from "@dto/create-iot-device.dto"; @@ -50,10 +50,15 @@ import { LoRaWANDevice } from "@entities/lorawan-device.entity"; import { SigFoxDevice } from "@entities/sigfox-device.entity"; import { AuditLog } from "@services/audit-log.service"; import { ActionType } from "@entities/audit-log-entry"; +import { IotDeviceBatchResponseDto } from "@dto/iot-device/iot-device-batch-response.dto"; +import { ArrayMaxSize } from "class-validator"; +import { CreateIoTDeviceBatchDto } from "@dto/iot-device/create-iot-device-batch.dto"; +import { UpdateIoTDeviceBatchDto } from "@dto/iot-device/update-iot-device-batch.dto"; +import { buildIoTDeviceCreateUpdateAuditData, ensureUpdatePayload as ensureIoTDeviceUpdatePayload } from "@helpers/iot-device.helper"; @ApiTags("IoT Device") @Controller("iot-device") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() @@ -222,6 +227,100 @@ export class IoTDeviceController { return iotDevice; } + @Post("createMany") + @Header("Cache-Control", "none") + @ApiOperation({ summary: "Create many IoT-Devices" }) + @ApiBadRequestResponse() + async createMany( + @Req() req: AuthenticatedRequest, + @Body() createDto: CreateIoTDeviceBatchDto + ): Promise { + try { + + createDto.data.forEach(createDto => checkIfUserHasWriteAccessToApplication(req, createDto.applicationId)); + const devices = await this.iotDeviceService.createMany( + createDto.data, + req.user.userId + ); + + // Iterate through the devices once, splitting it into a tuple with the data we want to log + const { deviceIds, deviceNames } = buildIoTDeviceCreateUpdateAuditData( + devices + ); + + if (!deviceIds.length) { + AuditLog.fail(ActionType.CREATE, IoTDevice.name, req.user.userId); + } else { + AuditLog.success( + ActionType.CREATE, + IoTDevice.name, + req.user.userId, + deviceIds.join(", "), + deviceNames.join(", ") + ); + } + return devices; + } catch (err) { + AuditLog.fail(ActionType.CREATE, IoTDevice.name, req.user.userId); + this.logger.error( + `Failed to create IoTDevice from dto: ${JSON.stringify( + createDto + )}. Error: ${err}` + ); + throw err; + } + } + + @Post("updateMany") + @Header("Cache-Control", "none") + @ApiOperation({ summary: "Update existing IoT-Devices" }) + @ApiBadRequestResponse() + async updateMany( + @Req() req: AuthenticatedRequest, + @Body() updateDto: UpdateIoTDeviceBatchDto + ): Promise { + const oldIotDevices = await this.iotDeviceService.findManyWithApplicationAndMetadata( + updateDto.data.map(iotDevice => iotDevice.id) + ); + const devicesNotFound: IotDeviceBatchResponseDto[] = []; + const validDevices: typeof updateDto = { data: [] }; + + try { + validDevices.data = updateDto.data.reduce( + ensureIoTDeviceUpdatePayload( + validDevices, + oldIotDevices, + devicesNotFound, + req + ), + [] + ); + } catch (err) { + AuditLog.fail(ActionType.UPDATE, IoTDevice.name, req.user.userId); + throw err; + } + + const response = validDevices.data.length + ? await this.iotDeviceService.updateMany(validDevices, req.user.userId) + : []; + response.push(...devicesNotFound); + + const { deviceIds, deviceNames } = buildIoTDeviceCreateUpdateAuditData(response); + + if (!deviceIds.length) { + AuditLog.fail(ActionType.CREATE, IoTDevice.name, req.user.userId); + } else { + AuditLog.success( + ActionType.CREATE, + IoTDevice.name, + req.user.userId, + deviceIds.join(", "), + deviceNames.join(", ") + ); + } + return response; + } + @Delete(":id") @ApiOperation({ summary: "Delete an existing IoT-Device" }) @ApiBadRequestResponse() diff --git a/src/controllers/admin-controller/multicast.controller.ts b/src/controllers/admin-controller/multicast.controller.ts new file mode 100644 index 00000000..c6488566 --- /dev/null +++ b/src/controllers/admin-controller/multicast.controller.ts @@ -0,0 +1,241 @@ +import { + Controller, + Get, + Post, + Body, + Put, + Param, + Delete, + Req, + UseGuards, + Query, + UnauthorizedException, + NotFoundException, + Header, + ParseIntPipe, + Logger, +} from "@nestjs/common"; +import { MulticastService } from "../../services/device-management/multicast.service"; +import { CreateMulticastDto } from "../../entities/dto/create-multicast.dto"; +import { UpdateMulticastDto } from "../../entities/dto/update-multicast.dto"; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiForbiddenResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; +import { Multicast } from "@entities/multicast.entity"; +import { + checkIfUserHasReadAccessToApplication, + checkIfUserHasWriteAccessToApplication, +} from "@helpers/security-helper"; +import { AuditLog } from "@services/audit-log.service"; +import { ActionType } from "@entities/audit-log-entry"; +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; +import { RolesGuard } from "@auth/roles.guard"; +import { Read, Write } from "@auth/roles.decorator"; +import { ListAllMulticastsDto } from "@dto/list-all-multicasts.dto"; +import { ListAllMulticastsResponseDto } from "@dto/list-all-multicasts-response.dto"; +import { ErrorCodes } from "@enum/error-codes.enum"; +import { DeleteResponseDto } from "@dto/delete-application-response.dto"; +import { MulticastDownlinkQueueResponseDto } from "@dto/chirpstack/chirpstack-multicast-downlink-queue-response.dto"; +import { CreateMulticastDownlinkDto } from "@dto/create-multicast-downlink.dto"; +import { CreateChirpstackMulticastQueueItemResponse } from "@dto/chirpstack/create-chirpstack-multicast-queue-item.dto"; + +@ApiTags("Multicast") +@UseGuards(ComposeAuthGuard, RolesGuard) +@ApiBearerAuth() +@Read() +@ApiForbiddenResponse() +@ApiUnauthorizedResponse() +@Controller("multicast") +export class MulticastController { + constructor(private readonly multicastService: MulticastService) {} + private readonly logger = new Logger(MulticastController.name); + + @Post() + @ApiOperation({ summary: "Create a new multicast" }) + @ApiBadRequestResponse() + async create( + @Req() req: AuthenticatedRequest, + @Body() createMulticastDto: CreateMulticastDto + ): Promise { + try { + checkIfUserHasWriteAccessToApplication(req, createMulticastDto.applicationID); + const multicast = await this.multicastService.create( + createMulticastDto, + req.user.userId + ); + AuditLog.success( + ActionType.CREATE, + Multicast.name, + req.user.userId, + multicast.id, + multicast.groupName + ); + return multicast; + } catch (err) { + AuditLog.fail(ActionType.CREATE, Multicast.name, req.user.userId); + throw err; + } + } + + @Get() + @ApiOperation({ summary: "Find all Multicasts" }) + async findAll( + @Req() req: AuthenticatedRequest, + @Query() query?: ListAllMulticastsDto + ): Promise { + const applicationId = +query.applicationId; + if (req.user.permissions.isGlobalAdmin) { + return await this.multicastService.findAndCountAllWithPagination(query); + } else { + if (query.applicationId) { + query.applicationId = applicationId; + } + + const allowed = req.user.permissions.getAllApplicationsWithAtLeastRead(); + if (applicationId && !allowed.some(x => x === applicationId)) { + throw new UnauthorizedException(); + } + + return await this.multicastService.findAndCountAllWithPagination( + query, + allowed + ); + } + } + + @Get(":id") + @ApiOperation({ summary: "Find Multicast by id" }) + async findOne( + @Req() req: AuthenticatedRequest, + @Param("id", new ParseIntPipe()) id: number + ): Promise { + try { + const multicast = await this.multicastService.findOne(id); + checkIfUserHasReadAccessToApplication(req, multicast.application.id); + return multicast; + } catch (err) { + throw new NotFoundException(ErrorCodes.IdDoesNotExists); + } + } + + @Get(":id/downlink-multicast") + @ApiOperation({ summary: "Get downlink queue for multicast" }) + async findMulticastDownlinkQueue( + @Req() req: AuthenticatedRequest, + @Param("id", new ParseIntPipe()) id: number + ): Promise { + let multicast = undefined; + try { + multicast = await this.multicastService.findOne(id); + } catch (err) { + this.logger.error(`Error occured during findOne: '${JSON.stringify(err)}'`); + } + + if (!multicast) { + throw new NotFoundException(ErrorCodes.IdDoesNotExists); + } + checkIfUserHasReadAccessToApplication(req, multicast.application.id); + + return this.multicastService.getDownlinkQueue( + multicast.lorawanMulticastDefinition.chirpstackGroupId + ); + } + + @Post(":id/downlink-multicast") + @Header("Cache-Control", "none") + @ApiOperation({ summary: "Schedule downlink multicast" }) + @ApiBadRequestResponse() + async createDownlink( + @Req() req: AuthenticatedRequest, + @Param("id", new ParseIntPipe()) id: number, + @Body() dto: CreateMulticastDownlinkDto + ): Promise { + try { + const multicast = await this.multicastService.findOne(id); + if (!multicast) { + throw new NotFoundException(); + } + checkIfUserHasWriteAccessToApplication(req, multicast.application.id); + const result = await this.multicastService.createDownlink(dto, multicast); + AuditLog.success(ActionType.CREATE, "Downlink", req.user.userId); + return result; + } catch (err) { + AuditLog.fail(ActionType.CREATE, "Downlink", req.user.userId); + throw err; + } + } + + @Put(":id") + @Header("Cache-Control", "none") + @ApiOperation({ summary: "Update an existing Multicast" }) + @ApiBadRequestResponse() + async update( + @Req() req: AuthenticatedRequest, + @Param("id", new ParseIntPipe()) id: number, + @Body() updateDto: UpdateMulticastDto + ): Promise { + const oldMulticast = await this.multicastService.findOne(id); + try { + checkIfUserHasWriteAccessToApplication(req, oldMulticast.application.id); + if (oldMulticast.application.id !== updateDto.applicationID) { + checkIfUserHasWriteAccessToApplication(req, updateDto.applicationID); + } + } catch (err) { + AuditLog.fail( + ActionType.UPDATE, + Multicast.name, + req.user.userId, + oldMulticast.lorawanMulticastDefinition.chirpstackGroupId, + oldMulticast.groupName + ); + throw err; + } + + const multicast = await this.multicastService.update( + oldMulticast, + updateDto, + req.user.userId + ); + AuditLog.success( + ActionType.UPDATE, + Multicast.name, + req.user.userId, + multicast.lorawanMulticastDefinition.chirpstackGroupId, + multicast.groupName + ); + return multicast; + } + + @Delete(":id") + @ApiOperation({ summary: "Delete an existing multicast" }) + @ApiBadRequestResponse() + @Write() + async delete( + @Req() req: AuthenticatedRequest, + @Param("id", new ParseIntPipe()) id: number + ): Promise { + try { + const multicast = await this.multicastService.findOne(id); + checkIfUserHasWriteAccessToApplication(req, multicast.application.id); + const result = await this.multicastService.multicastDelete(id, multicast); + + if (result.affected === 0) { + throw new NotFoundException(ErrorCodes.IdDoesNotExists); + } + AuditLog.success(ActionType.DELETE, Multicast.name, req.user.userId, id); + return new DeleteResponseDto(result.affected); + } catch (err) { + AuditLog.fail(ActionType.DELETE, Multicast.name, req.user.userId, id); + if (err?.status === 403) { + throw err; + } + throw new NotFoundException(err); + } + } +} diff --git a/src/controllers/admin-controller/payload-decoder.controller.ts b/src/controllers/admin-controller/payload-decoder.controller.ts index c39dda96..d8bbc35b 100644 --- a/src/controllers/admin-controller/payload-decoder.controller.ts +++ b/src/controllers/admin-controller/payload-decoder.controller.ts @@ -25,7 +25,7 @@ import { ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; import { Read, Write } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { CreatePayloadDecoderDto } from "@dto/create-payload-decoder.dto"; @@ -46,7 +46,7 @@ import { ActionType } from "@entities/audit-log-entry"; @ApiTags("Payload Decoder") @Controller("payload-decoder") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() diff --git a/src/controllers/admin-controller/search.controller.ts b/src/controllers/admin-controller/search.controller.ts index cb51f20b..ea1f5a5d 100644 --- a/src/controllers/admin-controller/search.controller.ts +++ b/src/controllers/admin-controller/search.controller.ts @@ -1,4 +1,4 @@ -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; import { Read } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; @@ -27,7 +27,7 @@ import { isNumber } from "lodash"; @Controller("search") @ApiTags("Search") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() diff --git a/src/controllers/admin-controller/sigfox/sigfox-api-contract.controller.ts b/src/controllers/admin-controller/sigfox/sigfox-api-contract.controller.ts index 1a7f87eb..c6d41f41 100644 --- a/src/controllers/admin-controller/sigfox/sigfox-api-contract.controller.ts +++ b/src/controllers/admin-controller/sigfox/sigfox-api-contract.controller.ts @@ -1,4 +1,4 @@ -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; import { Read } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; @@ -18,7 +18,7 @@ import { SigFoxGroupService } from "@services/sigfox/sigfox-group.service"; @ApiTags("SigFox") @Controller("sigfox-contract") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() diff --git a/src/controllers/admin-controller/sigfox/sigfox-api-device.controller.ts b/src/controllers/admin-controller/sigfox/sigfox-api-device.controller.ts index 542709c5..2165c21c 100644 --- a/src/controllers/admin-controller/sigfox/sigfox-api-device.controller.ts +++ b/src/controllers/admin-controller/sigfox/sigfox-api-device.controller.ts @@ -1,4 +1,4 @@ -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from "@auth/compose-auth.guard"; import { Read } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; @@ -18,7 +18,7 @@ import { SigFoxGroupService } from "@services/sigfox/sigfox-group.service"; @ApiTags("SigFox") @Controller("sigfox-api-device") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() diff --git a/src/controllers/admin-controller/sigfox/sigfox-device-type.controller.ts b/src/controllers/admin-controller/sigfox/sigfox-device-type.controller.ts index 605cf7f7..730a1f74 100644 --- a/src/controllers/admin-controller/sigfox/sigfox-device-type.controller.ts +++ b/src/controllers/admin-controller/sigfox/sigfox-device-type.controller.ts @@ -1,4 +1,4 @@ -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; import { Read, Write } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; @@ -45,7 +45,7 @@ import { SigFoxGroupService } from "@services/sigfox/sigfox-group.service"; @ApiTags("SigFox") @Controller("sigfox-device-type") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() diff --git a/src/controllers/admin-controller/sigfox/sigfox-group.controller.ts b/src/controllers/admin-controller/sigfox/sigfox-group.controller.ts index 22b86d15..51243eee 100644 --- a/src/controllers/admin-controller/sigfox/sigfox-group.controller.ts +++ b/src/controllers/admin-controller/sigfox/sigfox-group.controller.ts @@ -23,7 +23,7 @@ import { ApiTags, } from "@nestjs/swagger"; -import { JwtAuthGuard } from "@auth/jwt-auth.guard"; +import { ComposeAuthGuard } from '@auth/compose-auth.guard'; import { Read, Write } from "@auth/roles.decorator"; import { RolesGuard } from "@auth/roles.guard"; import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; @@ -45,7 +45,7 @@ import { ActionType } from "@entities/audit-log-entry"; @ApiTags("SigFox") @Controller("sigfox-group") -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(ComposeAuthGuard, RolesGuard) @ApiBearerAuth() @Read() @ApiForbiddenResponse() diff --git a/src/controllers/user-management/auth.controller.ts b/src/controllers/user-management/auth.controller.ts index ed326138..6fb75547 100644 --- a/src/controllers/user-management/auth.controller.ts +++ b/src/controllers/user-management/auth.controller.ts @@ -142,8 +142,7 @@ export class AuthController { @UseGuards(LocalAuthGuard) async login( @Request() req: AuthenticatedRequestLocalStrategy, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - @Body() body: LoginDto + @Body() _: LoginDto ): Promise { const { email, id } = req.user; return this.authService.issueJwt(email, id, false); @@ -165,7 +164,7 @@ export class AuthController { @Get("me") @ApiOperation({ summary: - "Get basic info on the current user and the organisations it has some permissions to.", + "Get basic info on the current user and the organizations it has some permissions to.", }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) diff --git a/src/entities/api-key-permission.entity.ts b/src/entities/api-key-permission.entity.ts new file mode 100644 index 00000000..4cbcca4e --- /dev/null +++ b/src/entities/api-key-permission.entity.ts @@ -0,0 +1,10 @@ +import { Permission } from "@entities/permission.entity"; +import { PermissionType } from "@enum/permission-type.enum"; +import { ChildEntity, ManyToMany } from "typeorm"; +import { ApiKey } from "./api-key.entity"; + +@ChildEntity(PermissionType.ApiKeyPermission) +export abstract class ApiKeyPermission extends Permission { + @ManyToMany(_ => ApiKey, key => key.permissions, { onDelete: "CASCADE" }) + apiKeys: ApiKey[]; +} diff --git a/src/entities/api-key.entity.ts b/src/entities/api-key.entity.ts new file mode 100644 index 00000000..da650106 --- /dev/null +++ b/src/entities/api-key.entity.ts @@ -0,0 +1,34 @@ +import { User } from "@entities/user.entity"; +import { nameof } from "@helpers/type-helper"; +import { + Column, + Entity, + JoinColumn, + JoinTable, + ManyToMany, + OneToOne, + Unique, +} from "typeorm"; +import { ApiKeyPermission } from "./api-key-permission.entity"; +import { DbBaseEntity } from "./base.entity"; + +@Entity("api_key") +@Unique([nameof("key")]) +export class ApiKey extends DbBaseEntity { + @Column() + key: string; + + @Column() + name: string; + + @ManyToMany(_ => ApiKeyPermission, apiKeyPm => apiKeyPm.apiKeys) + @JoinTable() + permissions: ApiKeyPermission[]; + + @OneToOne(() => User, u => u.apiKeyRef, { + nullable: false, + cascade: true, + }) + @JoinColumn() + systemUser: User; +} diff --git a/src/entities/application.entity.ts b/src/entities/application.entity.ts index c38248e5..3b093c9b 100644 --- a/src/entities/application.entity.ts +++ b/src/entities/application.entity.ts @@ -13,6 +13,8 @@ import { DataTarget } from "@entities/data-target.entity"; import { IoTDevice } from "@entities/iot-device.entity"; import { OrganizationApplicationPermission } from "@entities/organization-application-permission.entity"; import { Organization } from "@entities/organization.entity"; +import { Multicast } from "./multicast.entity"; + @Entity("application") @Unique(["name"]) @@ -39,6 +41,14 @@ export class Application extends DbBaseEntity { ) dataTargets: DataTarget[]; + @OneToMany( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type => Multicast, + multicasts => multicasts.application, + { onDelete: "CASCADE" } + ) + multicasts: Multicast[]; + @ManyToOne( // eslint-disable-next-line @typescript-eslint/no-unused-vars type => Organization, diff --git a/src/entities/dto/api-key/api-key-response.dto.ts b/src/entities/dto/api-key/api-key-response.dto.ts new file mode 100644 index 00000000..3e42659e --- /dev/null +++ b/src/entities/dto/api-key/api-key-response.dto.ts @@ -0,0 +1,4 @@ +import { ApiKey } from "@entities/api-key.entity"; +import { OmitType } from "@nestjs/swagger"; + +export class ApiKeyResponseDto extends OmitType(ApiKey, ["key"] as const) {} diff --git a/src/entities/dto/api-key/create-api-key.dto.ts b/src/entities/dto/api-key/create-api-key.dto.ts new file mode 100644 index 00000000..0766c086 --- /dev/null +++ b/src/entities/dto/api-key/create-api-key.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { ArrayNotEmpty, ArrayUnique, IsArray, IsString, Length } from "class-validator"; + +export class CreateApiKeyDto { + @ApiProperty({ required: true }) + @IsString() + @Length(2, 50) + name: string; + + @ApiProperty({ + required: true, + type: "array", + items: { + type: "number", + }, + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + permissionIds: number[]; +} diff --git a/src/entities/dto/api-key/list-all-api-keys-response.dto.ts b/src/entities/dto/api-key/list-all-api-keys-response.dto.ts new file mode 100644 index 00000000..84582b25 --- /dev/null +++ b/src/entities/dto/api-key/list-all-api-keys-response.dto.ts @@ -0,0 +1,4 @@ +import { ListAllEntitiesResponseDto } from "@dto/list-all-entities-response.dto"; +import { ApiKeyResponseDto } from "./api-key-response.dto"; + +export class ListAllApiKeysResponseDto extends ListAllEntitiesResponseDto {} diff --git a/src/entities/dto/api-key/list-all-api-keys.dto.ts b/src/entities/dto/api-key/list-all-api-keys.dto.ts new file mode 100644 index 00000000..51a7664e --- /dev/null +++ b/src/entities/dto/api-key/list-all-api-keys.dto.ts @@ -0,0 +1,7 @@ +import { ListAllEntitiesDto } from "@dto/list-all-entities.dto"; +import { ApiProperty } from "@nestjs/swagger"; + +export class ListAllApiKeysDto extends ListAllEntitiesDto { + @ApiProperty({ required: true }) + organizationId: number; +} diff --git a/src/entities/dto/api-key/update-api-key.dto.ts b/src/entities/dto/api-key/update-api-key.dto.ts new file mode 100644 index 00000000..9803f5ee --- /dev/null +++ b/src/entities/dto/api-key/update-api-key.dto.ts @@ -0,0 +1,3 @@ +import { CreateApiKeyDto } from "./create-api-key.dto"; + +export class UpdateApiKeyDto extends CreateApiKeyDto {} diff --git a/src/entities/dto/chirpstack-add-device-multicast.dto.ts b/src/entities/dto/chirpstack-add-device-multicast.dto.ts new file mode 100644 index 00000000..9c26f6b1 --- /dev/null +++ b/src/entities/dto/chirpstack-add-device-multicast.dto.ts @@ -0,0 +1,4 @@ +export class AddDeviceToMulticastDto { + devEUI: string; + multicastGroupID: string; +} diff --git a/src/entities/dto/chirpstack/chirpstack-device-id.dto.ts b/src/entities/dto/chirpstack/chirpstack-device-id.dto.ts new file mode 100644 index 00000000..936306e6 --- /dev/null +++ b/src/entities/dto/chirpstack/chirpstack-device-id.dto.ts @@ -0,0 +1,3 @@ +import { ChirpstackDeviceContentsDto } from "./chirpstack-device-contents.dto"; + +export type ChirpstackDeviceId = Pick; diff --git a/src/entities/dto/chirpstack/chirpstack-multicast-contents.dto.ts b/src/entities/dto/chirpstack/chirpstack-multicast-contents.dto.ts new file mode 100644 index 00000000..d8297465 --- /dev/null +++ b/src/entities/dto/chirpstack/chirpstack-multicast-contents.dto.ts @@ -0,0 +1,27 @@ +import { multicastGroup } from "@enum/multicast-type.enum"; +import { ApiProperty } from "@nestjs/swagger"; +import { Matches } from "class-validator"; + +export class ChirpstackMulticastContentsDto { + @ApiProperty({ required: true }) + applicationID: string; + @ApiProperty({ required: true }) + dr: number; + @ApiProperty({ required: true }) + fCnt: number; + @ApiProperty({ required: true }) + frequency: number; + @ApiProperty({ required: true }) + groupType: multicastGroup; + @ApiProperty({ required: true }) + mcAddr: string; + @ApiProperty({ required: true }) + mcAppSKey: string; + @ApiProperty({ required: true }) + mcNwkSKey: string; + @ApiProperty({ required: true }) + name: string; + @ApiProperty({ required: false }) + pingSlotPeriod: number; + +} diff --git a/src/entities/dto/chirpstack/chirpstack-multicast-downlink-queue-response.dto.ts b/src/entities/dto/chirpstack/chirpstack-multicast-downlink-queue-response.dto.ts new file mode 100644 index 00000000..089f7189 --- /dev/null +++ b/src/entities/dto/chirpstack/chirpstack-multicast-downlink-queue-response.dto.ts @@ -0,0 +1,13 @@ +export interface MulticastQueueItem { + devEUI?: string; + confirmed?: boolean; + fCnt?: number; + fPort?: number; + data: string; + jsonObject?: string; +} + +export interface MulticastDownlinkQueueResponseDto { + deviceQueueItems: MulticastQueueItem[]; + totalCount: number; +} diff --git a/src/entities/dto/chirpstack/chirpstack-paginated-list.dto.ts b/src/entities/dto/chirpstack/chirpstack-paginated-list.dto.ts index d16e5e57..8752d23a 100644 --- a/src/entities/dto/chirpstack/chirpstack-paginated-list.dto.ts +++ b/src/entities/dto/chirpstack/chirpstack-paginated-list.dto.ts @@ -1,10 +1,14 @@ import { ApiProperty } from "@nestjs/swagger"; +import { IsOptional } from "class-validator"; export class ChirpstackPaginatedListDto { @ApiProperty({ type: Number, required: false }) + @IsOptional() limit? = 100; @ApiProperty({ type: Number, required: false }) + @IsOptional() offset? = 0; @ApiProperty({ type: Number, required: false }) + @IsOptional() organizationId?: number; } diff --git a/src/entities/dto/chirpstack/create-chirpstack-multicast-queue-item.dto.ts b/src/entities/dto/chirpstack/create-chirpstack-multicast-queue-item.dto.ts new file mode 100644 index 00000000..bfc2a932 --- /dev/null +++ b/src/entities/dto/chirpstack/create-chirpstack-multicast-queue-item.dto.ts @@ -0,0 +1,13 @@ +export interface CreateChirpstackMulticastQueueItemDto { + multicastQueueItem: MulticastQueueItem; +} + +export interface MulticastQueueItem { + fPort: number; + data: string; + multicastGroupID: string; +} + +export interface CreateChirpstackMulticastQueueItemResponse { + fCnt: number; +} diff --git a/src/entities/dto/chirpstack/create-multicast-chirpstack.dto.ts b/src/entities/dto/chirpstack/create-multicast-chirpstack.dto.ts new file mode 100644 index 00000000..44a21e52 --- /dev/null +++ b/src/entities/dto/chirpstack/create-multicast-chirpstack.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; +import { ChirpstackMulticastContentsDto } from "./chirpstack-multicast-contents.dto"; + +export class CreateMulticastChirpStackDto { + @ApiProperty({ required: true }) + @ValidateNested({ each: true }) + @Type(() => ChirpstackMulticastContentsDto) + multicastGroup: ChirpstackMulticastContentsDto; +} diff --git a/src/entities/dto/create-data-target.dto.ts b/src/entities/dto/create-data-target.dto.ts index 1099fcb1..ae0be06b 100644 --- a/src/entities/dto/create-data-target.dto.ts +++ b/src/entities/dto/create-data-target.dto.ts @@ -23,6 +23,12 @@ export class CreateDataTargetDto { @MaxLength(50) name: string; + @ApiProperty({ required: false, default: "" }) + tenant: string; + + @ApiProperty({ required: false, default: "", example: null }) + context: string; + @ApiProperty({ required: true, example: 1 }) @IsNumber() @Min(1) @@ -35,7 +41,7 @@ export class CreateDataTargetDto { @IsString() @MaxLength(1024) @IsNotBlank("url") - @IsUrl() + @IsUrl({ require_tld: false, require_protocol: true}) url: string; @ApiProperty({ required: true, example: 30000 }) diff --git a/src/entities/dto/create-device-model.dto.ts b/src/entities/dto/create-device-model.dto.ts index c55c5c70..64ed0da1 100644 --- a/src/entities/dto/create-device-model.dto.ts +++ b/src/entities/dto/create-device-model.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsNumber } from "class-validator"; +import { IsDefined, IsNumber } from "class-validator"; export class CreateDeviceModelDto { @ApiProperty({ required: true }) @@ -7,5 +7,7 @@ export class CreateDeviceModelDto { belongsToId: number; @ApiProperty({ required: true }) + // @IsJSON or @IsString does not work. Will be validated during the flow + @IsDefined() body: JSON; } diff --git a/src/entities/dto/create-multicast-downlink.dto.ts b/src/entities/dto/create-multicast-downlink.dto.ts new file mode 100644 index 00000000..cdea6d1a --- /dev/null +++ b/src/entities/dto/create-multicast-downlink.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsHexadecimal, IsInt, Min } from "class-validator"; + +export class CreateMulticastDownlinkDto { + @ApiProperty({ required: true }) + @IsHexadecimal() + data: string; + + @ApiProperty({ required: true, example: 1 }) + @IsInt() + @Min(1) + port: number; +} diff --git a/src/entities/dto/create-multicast.dto.ts b/src/entities/dto/create-multicast.dto.ts new file mode 100644 index 00000000..799d034b --- /dev/null +++ b/src/entities/dto/create-multicast.dto.ts @@ -0,0 +1,70 @@ +import { IoTDevice } from "@entities/iot-device.entity"; +import { multicastGroup } from "@enum/multicast-type.enum"; +import { ApiProperty } from "@nestjs/swagger"; +import { + IsHexadecimal, + IsInt, + IsNumber, + IsString, + MaxLength, + Min, + MinLength, +} from "class-validator"; + +export class CreateMulticastDto { + @ApiProperty({ required: true }) + @IsString() + @MinLength(1) + @MaxLength(50) + name: string; + + @ApiProperty({ required: true, example: 1 }) + @IsNumber() + @Min(1) + applicationID: number; + + @ApiProperty({ required: true }) + @IsHexadecimal() + @MinLength(8) + @MaxLength(8) + mcAddr: string; + + @ApiProperty({ required: true }) + @IsHexadecimal() + @MaxLength(32) + @MinLength(32) + mcNwkSKey: string; + + @ApiProperty({ required: true }) + @IsHexadecimal() + @MaxLength(32) + @MinLength(32) + mcAppSKey: string; + + @ApiProperty({ required: true, example: 300 }) + @IsInt() + @Min(0) + fCnt: number; + + @ApiProperty({ required: true, example: 300 }) + @IsInt() + @Min(0) + dr: number; + + @ApiProperty({ required: true, example: 300 }) + @IsInt() + @Min(0) + frequency: number; + + @ApiProperty({ required: true }) + @IsString() + @MaxLength(32) + @MinLength(1) + groupType: multicastGroup; + + @ApiProperty({ required: true }) + multicastId: string; + + @ApiProperty({ required: false }) + iotDevices: IoTDevice[]; +} diff --git a/src/entities/dto/iot-device/create-iot-device-batch.dto.ts b/src/entities/dto/iot-device/create-iot-device-batch.dto.ts new file mode 100644 index 00000000..40ec95b0 --- /dev/null +++ b/src/entities/dto/iot-device/create-iot-device-batch.dto.ts @@ -0,0 +1,8 @@ +import { CreateIoTDeviceDto } from "@dto/create-iot-device.dto"; +import { ArrayMaxSize, ArrayNotEmpty } from "class-validator"; + +export class CreateIoTDeviceBatchDto { + @ArrayNotEmpty() + @ArrayMaxSize(50) + data: CreateIoTDeviceDto[]; +} diff --git a/src/entities/dto/iot-device/create-iot-device-map.dto.ts b/src/entities/dto/iot-device/create-iot-device-map.dto.ts new file mode 100644 index 00000000..eb803945 --- /dev/null +++ b/src/entities/dto/iot-device/create-iot-device-map.dto.ts @@ -0,0 +1,20 @@ +import { CreateIoTDeviceDto } from "@dto/create-iot-device.dto"; +import { IoTDevice } from "@entities/iot-device.entity"; + +/** + * Represents an IoT device payload from a client + */ +export type CreateIoTDeviceMapDto = { + /** + * Client payload + */ + iotDeviceDto: CreateIoTDeviceDto; + /** + * If an operation on the dto succeeds, this should be set + */ + iotDevice?: IoTDevice; + /** + * If an operation on the dto fails, this should be set + */ + error?: Omit; +}; diff --git a/src/entities/dto/iot-device/iot-device-batch-response.dto.ts b/src/entities/dto/iot-device/iot-device-batch-response.dto.ts new file mode 100644 index 00000000..d35dbe4e --- /dev/null +++ b/src/entities/dto/iot-device/iot-device-batch-response.dto.ts @@ -0,0 +1,13 @@ +import { IoTDevice } from "@entities/iot-device.entity"; + +export class IotDeviceBatchResponseDto { + data?: IoTDevice; + /** + * Identification metadata about the payload in case it was not valid + */ + idMetadata: { + name: string; + applicationId: number; + }; + error?: Omit; +} diff --git a/src/entities/dto/iot-device/update-iot-device-batch.dto.ts b/src/entities/dto/iot-device/update-iot-device-batch.dto.ts new file mode 100644 index 00000000..e0a39b78 --- /dev/null +++ b/src/entities/dto/iot-device/update-iot-device-batch.dto.ts @@ -0,0 +1,11 @@ +import { UpdateIoTDeviceDto } from "@dto/update-iot-device.dto"; +import { ArrayMaxSize, ArrayNotEmpty, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; + +export class UpdateIoTDeviceBatchDto { + @ArrayNotEmpty() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => UpdateIoTDeviceDto) + data: UpdateIoTDeviceDto[]; +} diff --git a/src/entities/dto/list-all-entities.dto.ts b/src/entities/dto/list-all-entities.dto.ts index 794228d7..a5c7fdfa 100644 --- a/src/entities/dto/list-all-entities.dto.ts +++ b/src/entities/dto/list-all-entities.dto.ts @@ -1,13 +1,23 @@ +import { StringToNumber } from "@helpers/string-to-number-validator"; import { ApiProperty } from "@nestjs/swagger"; +import { IsOptional, IsString } from "class-validator"; export class ListAllEntitiesDto { @ApiProperty({ type: Number, required: false }) + @IsOptional() + @StringToNumber() limit? = 100; @ApiProperty({ type: Number, required: false }) + @IsOptional() + @StringToNumber() offset? = 0; @ApiProperty({ type: String, required: false }) + @IsOptional() + @IsString() sort?: "ASC" | "DESC"; @ApiProperty({ type: String, required: false }) + @IsOptional() + @IsString() orderOn?: | "id" | "name" @@ -16,5 +26,6 @@ export class ListAllEntitiesDto { | "lastLogin" | "type" | "organisations" - | "active"; + | "active" + | "groupName"; } diff --git a/src/entities/dto/list-all-iot-devices-minimal-response.dto.ts b/src/entities/dto/list-all-iot-devices-minimal-response.dto.ts index 67e70b4b..ce01c041 100644 --- a/src/entities/dto/list-all-iot-devices-minimal-response.dto.ts +++ b/src/entities/dto/list-all-iot-devices-minimal-response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsSwaggerOptional } from "@helpers/optional-validator"; export class ListAllIoTDevicesMinimalResponseDto { @ApiProperty() @@ -34,9 +35,9 @@ export class IoTDeviceMinimalRaw { } export class PayloadDecoderIoDeviceMinimalQuery { - @ApiPropertyOptional() - limit?: number = 20; + @IsSwaggerOptional() + limit? = 20; - @ApiPropertyOptional() - offset?: number = 0; + @IsSwaggerOptional() + offset? = 0; } diff --git a/src/entities/dto/list-all-multicasts-response.dto.ts b/src/entities/dto/list-all-multicasts-response.dto.ts new file mode 100644 index 00000000..dba23ed5 --- /dev/null +++ b/src/entities/dto/list-all-multicasts-response.dto.ts @@ -0,0 +1,4 @@ +import { ListAllEntitiesResponseDto } from "@dto/list-all-entities-response.dto"; +import { Multicast } from "@entities/multicast.entity"; + +export class ListAllMulticastsResponseDto extends ListAllEntitiesResponseDto {} diff --git a/src/entities/dto/list-all-multicasts.dto.ts b/src/entities/dto/list-all-multicasts.dto.ts new file mode 100644 index 00000000..a27155c2 --- /dev/null +++ b/src/entities/dto/list-all-multicasts.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from "@nestjs/swagger"; + +import { ListAllEntitiesDto } from "./list-all-entities.dto"; + +export class ListAllMulticastsDto extends ListAllEntitiesDto { + @ApiProperty({ + type: Number, + required: false, + description: + "Limit the results to the multicasts associated with a single application", + }) + applicationId?: number; +} diff --git a/src/entities/dto/list-all-paginated.dto.ts b/src/entities/dto/list-all-paginated.dto.ts index 24665158..cd22d6b3 100644 --- a/src/entities/dto/list-all-paginated.dto.ts +++ b/src/entities/dto/list-all-paginated.dto.ts @@ -1,8 +1,8 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { IsSwaggerOptional } from "@helpers/optional-validator"; export class ListAllPaginated { - @ApiProperty({ type: Number, required: false }) + @IsSwaggerOptional({ type: Number }) limit? = 100; - @ApiProperty({ type: Number, required: false }) + @IsSwaggerOptional({ type: Number }) offset? = 0; } diff --git a/src/entities/dto/login.dto.ts b/src/entities/dto/login.dto.ts index b24669dd..eaae5a64 100644 --- a/src/entities/dto/login.dto.ts +++ b/src/entities/dto/login.dto.ts @@ -1,8 +1,11 @@ import { ApiProperty } from "@nestjs/swagger"; +import { IsString } from "class-validator"; export class LoginDto { @ApiProperty({ default: "john@localhost.dk" }) + @IsString() username: string; @ApiProperty({ default: "hunter2" }) + @IsString() password: string; } diff --git a/src/entities/dto/lorawan-device-id.dto.ts b/src/entities/dto/lorawan-device-id.dto.ts new file mode 100644 index 00000000..a6ec0848 --- /dev/null +++ b/src/entities/dto/lorawan-device-id.dto.ts @@ -0,0 +1,3 @@ +import { LoRaWANDevice } from "@entities/lorawan-device.entity"; + +export type LoRaWANDeviceId = Pick; diff --git a/src/entities/dto/receive-data.dto.ts b/src/entities/dto/receive-data.dto.ts index 74c19872..55ec935a 100644 --- a/src/entities/dto/receive-data.dto.ts +++ b/src/entities/dto/receive-data.dto.ts @@ -1,6 +1,15 @@ +import { Exclude } from "class-transformer"; +import { IsOptional } from "class-validator"; + /** * This only exists to nudge Swagger to make an JSON body for us to post. * - * Intentionally left blank. + * Validation won't work for empty objects and we can't disable it, seemingly. + * + * @see https://github.com/typestack/class-validator/issues/1503 */ -export class ReceiveDataDto {} +export class ReceiveDataDto { + @Exclude() + @IsOptional() + ignoreMe: unknown; +} diff --git a/src/entities/dto/sigfox/internal/create-sigfox-group-request.dto.ts b/src/entities/dto/sigfox/internal/create-sigfox-group-request.dto.ts index 0f533576..9060f24c 100644 --- a/src/entities/dto/sigfox/internal/create-sigfox-group-request.dto.ts +++ b/src/entities/dto/sigfox/internal/create-sigfox-group-request.dto.ts @@ -1,12 +1,16 @@ import { ApiProperty } from "@nestjs/swagger"; +import { IsNumber, IsString } from "class-validator"; export class CreateSigFoxGroupRequestDto { @ApiProperty({ required: true }) + @IsNumber() organizationId: number; @ApiProperty({ required: true }) + @IsString() username: string; @ApiProperty({ required: true }) + @IsString() password: string; } diff --git a/src/entities/dto/sigfox/internal/sigfox-get-all-request.dto.ts b/src/entities/dto/sigfox/internal/sigfox-get-all-request.dto.ts index def69568..7bd0dbe8 100644 --- a/src/entities/dto/sigfox/internal/sigfox-get-all-request.dto.ts +++ b/src/entities/dto/sigfox/internal/sigfox-get-all-request.dto.ts @@ -1,3 +1,6 @@ +import { StringToNumber } from "@helpers/string-to-number-validator"; + export class SigFoxGetAllRequestDto { + @StringToNumber() organizationId: number; } diff --git a/src/entities/dto/sigfox/sigfox-callback.dto.ts b/src/entities/dto/sigfox/sigfox-callback.dto.ts index 6ff544d9..23188ff0 100644 --- a/src/entities/dto/sigfox/sigfox-callback.dto.ts +++ b/src/entities/dto/sigfox/sigfox-callback.dto.ts @@ -1,23 +1,37 @@ +import { IsNumber, IsOptional, IsString } from "class-validator"; + /** * Callback as expected from SigFox * Docs: https://support.sigfox.com/docs/uplink */ export class SigFoxCallbackDto { + @IsNumber() time: number; + @IsString() deviceTypeId: string; + @IsString() deviceId: string; + @IsString() data: string; + @IsNumber() seqNumber: number; // If true, then the device expects a downlink ack: boolean; // Only included in BIDIR + @IsOptional() longPolling?: boolean; // these are not available for all contracts "Condition: for devices with contract option NETWORK METADATA" // https://support.sigfox.com/docs/bidir // We cannot assume they'll exists + @IsOptional() + @IsNumber() snr?: number; + @IsOptional() + @IsNumber() rssi?: number; + @IsOptional() + @IsString() station?: string; } diff --git a/src/entities/dto/test-payload-decoder.dto.ts b/src/entities/dto/test-payload-decoder.dto.ts index bc8a6873..61e28ce3 100644 --- a/src/entities/dto/test-payload-decoder.dto.ts +++ b/src/entities/dto/test-payload-decoder.dto.ts @@ -1,5 +1,10 @@ +import { IsString } from "class-validator"; + export class TestPayloadDecoderDto { + @IsString() code: string; + @IsString() iotDeviceJsonString: string; + @IsString() rawPayloadJsonString: string; } diff --git a/src/entities/dto/update-iot-device.dto.ts b/src/entities/dto/update-iot-device.dto.ts index 6e80a245..3b183e8a 100644 --- a/src/entities/dto/update-iot-device.dto.ts +++ b/src/entities/dto/update-iot-device.dto.ts @@ -1,3 +1,9 @@ import { CreateIoTDeviceDto } from "@dto/create-iot-device.dto"; +import { StringToNumber } from "@helpers/string-to-number-validator"; +import { Min } from "class-validator"; -export class UpdateIoTDeviceDto extends CreateIoTDeviceDto {} +export class UpdateIoTDeviceDto extends CreateIoTDeviceDto { + @StringToNumber() + @Min(1) + id: number; +} diff --git a/src/entities/dto/update-multicast.dto.ts b/src/entities/dto/update-multicast.dto.ts new file mode 100644 index 00000000..6728ac8d --- /dev/null +++ b/src/entities/dto/update-multicast.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/mapped-types"; +import { CreateMulticastDto } from "./create-multicast.dto"; + +export class UpdateMulticastDto extends PartialType(CreateMulticastDto) {} diff --git a/src/entities/dto/user-management/create-organization.dto.ts b/src/entities/dto/user-management/create-organization.dto.ts index b40641aa..c1dd3c47 100644 --- a/src/entities/dto/user-management/create-organization.dto.ts +++ b/src/entities/dto/user-management/create-organization.dto.ts @@ -1,3 +1,6 @@ +import { IsString } from "class-validator"; + export class CreateOrganizationDto { + @IsString() name: string; } diff --git a/src/entities/enum/data-target-type-mapping.ts b/src/entities/enum/data-target-type-mapping.ts index c24efa59..2a18148b 100644 --- a/src/entities/enum/data-target-type-mapping.ts +++ b/src/entities/enum/data-target-type-mapping.ts @@ -1,6 +1,9 @@ import { HttpPushDataTarget } from "@entities/http-push-data-target.entity"; +import { FiwareDataTarget } from "@entities/fiware-data-target.entity"; import { DataTargetType } from "@enum/data-target-type.enum"; export const dataTargetTypeMap = { [DataTargetType.HttpPush]: HttpPushDataTarget, + [DataTargetType.Fiware]: FiwareDataTarget, + }; diff --git a/src/entities/enum/data-target-type.enum.ts b/src/entities/enum/data-target-type.enum.ts index 1d13e22f..b513795f 100644 --- a/src/entities/enum/data-target-type.enum.ts +++ b/src/entities/enum/data-target-type.enum.ts @@ -1,3 +1,4 @@ export enum DataTargetType { HttpPush = "HTTP_PUSH", + Fiware = "FIWARE", } diff --git a/src/entities/enum/error-codes.enum.ts b/src/entities/enum/error-codes.enum.ts index 03bb163d..ddeaedb4 100644 --- a/src/entities/enum/error-codes.enum.ts +++ b/src/entities/enum/error-codes.enum.ts @@ -13,7 +13,7 @@ export enum ErrorCodes { UserAlreadyExists = "MESSAGE.USER-ALREADY-EXISTS", OrganizationAlreadyExists = "MESSAGE.ORGANIZATION-ALREADY-EXISTS", OrganizationDoesNotExists = "MESSAGE.ORGANIZATION-DOES-NOT-EXISTS", - OrganizationDoesNotMatch = "MESSAGE.ORGANIZATION-DOES-NOT-MATCH", + DeviceModelOrganizationDoesNotMatch = "MESSAGE.DEVICE-MODEL-ORGANIZATION-DOES-NOT-MATCH", UserInactive = "MESSAGE.USER-INACTIVE", NotSameApplication = "MESSAGE.NOT-SAME-APPLICATION", PasswordNotMetRequirements = "MESSAGE.PASSWORD-DOES-NOT-MEET-REQUIREMENTS", @@ -35,4 +35,11 @@ export enum ErrorCodes { DeleteNotAllowedHasSigfoxDevice = "MESSAGE.DELETE-NOT-ALLOWED-HAS-SIGFOX-DEVICE", DeleteNotAllowedHasLoRaWANDevices = "MESSAGE.DELETE-NOT-ALLOWED-HAS-LORAWAN-DEVICE", KOMBITLoginFailed = "MESSAGE.KOMBIT-LOGIN-FAILED", + ApiKeyAuthFailed = "MESSAGE.API-KEY-AUTH-FAILED", + DifferentServiceprofile = "MESSAGE.DIFFERENT-SERVICE-PROFILE", + NewDevicesWrongServiceProfile = "MESSAGE.WRONG-SERVICE-PROFILE", + TooMuchData = "MESSAGE.TOO-MUCH-DATA", + ApplicationDoesNotExist = "MESSAGE.APPLICATION-DOES-NOT-EXIST", + FailedToCreateOrUpdateIotDevice = "MESSAGE.FAILED-TO-CREATE-OR-UPDATE-IOT-DEVICE", + DeviceModelDoesNotExist = "MESSAGE.DEVICE-MODEL-DOES-NOT-EXIST", } diff --git a/src/entities/enum/multicast-type.enum.ts b/src/entities/enum/multicast-type.enum.ts new file mode 100644 index 00000000..7141a31a --- /dev/null +++ b/src/entities/enum/multicast-type.enum.ts @@ -0,0 +1,3 @@ +export enum multicastGroup { + ClassC = "CLASS_C", +} diff --git a/src/entities/enum/permission-type.enum.ts b/src/entities/enum/permission-type.enum.ts index b584f1fe..aef46d09 100644 --- a/src/entities/enum/permission-type.enum.ts +++ b/src/entities/enum/permission-type.enum.ts @@ -5,4 +5,5 @@ export enum PermissionType { Read = "Read", OrganizationPermission = "OrganizationPermission", OrganizationApplicationPermissions = "OrganizationApplicationPermissions", + ApiKeyPermission = "ApiKeyPermission", } diff --git a/src/entities/fiware-data-target.entity.ts b/src/entities/fiware-data-target.entity.ts new file mode 100644 index 00000000..7146ccc4 --- /dev/null +++ b/src/entities/fiware-data-target.entity.ts @@ -0,0 +1,44 @@ +import { BeforeInsert, ChildEntity, Column } from "typeorm"; + +import { DataTarget } from "@entities/data-target.entity"; +import { AuthorizationType } from "@enum/authorization-type.enum"; +import { DataTargetType } from "@enum/data-target-type.enum"; + +import { FiwareDataTargetConfiguration } from "./interfaces/fiware-data-target-configuration.interface"; + +@ChildEntity(DataTargetType.Fiware) +export class FiwareDataTarget extends DataTarget { + @Column() + url: string; + + @Column({ default: 30000, comment: "HTTP call timeout in milliseconds" }) + timeout: number; + + @Column({ nullable: true }) + authorizationHeader?: string; + + @Column({ nullable: true }) + tenant: string; + + @Column({ nullable: true }) + context: string; + + @BeforeInsert() + private beforeInsert() { + this.type = DataTargetType.Fiware; + } + + toConfiguration(): FiwareDataTargetConfiguration { + return { + url: this.url, + timeout: this.timeout, + authorizationType: + this.authorizationHeader != "" + ? AuthorizationType.HEADER_BASED_AUTHORIZATION + : AuthorizationType.NO_AUTHORIZATION, + authorizationHeader: this.authorizationHeader, + tenant: this.tenant, + context: this.context + }; + } +} diff --git a/src/entities/interfaces/fiware-data-target-configuration.interface.ts b/src/entities/interfaces/fiware-data-target-configuration.interface.ts new file mode 100644 index 00000000..2ddd3c23 --- /dev/null +++ b/src/entities/interfaces/fiware-data-target-configuration.interface.ts @@ -0,0 +1,12 @@ +import { AuthorizationType } from "@enum/authorization-type.enum"; + +export interface FiwareDataTargetConfiguration { + url: string; + timeout: number; + authorizationType: AuthorizationType; + username?: string; + password?: string; + authorizationHeader?: string; + tenant?: string; + context?: string; +} diff --git a/src/entities/iot-device.entity.ts b/src/entities/iot-device.entity.ts index b8d3f6b0..b8b34b6e 100644 --- a/src/entities/iot-device.entity.ts +++ b/src/entities/iot-device.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, JoinColumn, + JoinTable, ManyToMany, ManyToOne, OneToMany, @@ -18,6 +19,7 @@ import { ReceivedMessage } from "@entities/received-message.entity"; import { ReceivedMessageMetadata } from "@entities/received-message-metadata.entity"; import { IoTDeviceType } from "@enum/device-type.enum"; import { DeviceModel } from "./device-model.entity"; +import { Multicast } from "./multicast.entity"; @Entity("iot_device") @TableInheritance({ @@ -87,6 +89,10 @@ export abstract class IoTDevice extends DbBaseEntity { @JoinColumn() deviceModel?: DeviceModel; + @ManyToMany(() => Multicast) + @JoinTable() + multicasts: Multicast[]; + toString(): string { return `IoTDevices: id: ${this.id} - name: ${this.name}`; } diff --git a/src/entities/lorawan-multicast.entity.ts b/src/entities/lorawan-multicast.entity.ts new file mode 100644 index 00000000..bec8fe3f --- /dev/null +++ b/src/entities/lorawan-multicast.entity.ts @@ -0,0 +1,35 @@ +import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from "typeorm"; + +import { multicastGroup } from "@enum/multicast-type.enum"; +import { Multicast } from "./multicast.entity"; +import { DbBaseEntity } from "./base.entity"; + +@Entity("lorawan-multicast") +export class LorawanMulticastDefinition extends DbBaseEntity { + @Column() + address: string; + + @Column() + networkSessionKey: string; + + @Column() + applicationSessionKey: string; + + @Column() + frameCounter: number; + + @Column() + dataRate: number; + + @Column() + frequency: number; + + @Column() + groupType: multicastGroup; + + @OneToOne(type => Multicast, multicast => multicast.lorawanMulticastDefinition) + multicast: Multicast; + + @Column({nullable: true}) + chirpstackGroupId?: string; +} diff --git a/src/entities/multicast.entity.ts b/src/entities/multicast.entity.ts new file mode 100644 index 00000000..c3371eed --- /dev/null +++ b/src/entities/multicast.entity.ts @@ -0,0 +1,39 @@ +import { + Column, + Entity, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToOne, +} from "typeorm"; + +import { Application } from "@entities/application.entity"; +import { IoTDevice } from "./iot-device.entity"; +import { LorawanMulticastDefinition } from "./lorawan-multicast.entity"; +import { DbBaseEntity } from "./base.entity"; + +@Entity("multicast") +export class Multicast extends DbBaseEntity { + @Column() + groupName: string; + + @ManyToOne( + _ => Application, + application => application.multicasts, + { onDelete: "CASCADE" } + ) + application: Application; + + @ManyToMany(() => IoTDevice, iotDevices => iotDevices.multicasts) + @JoinTable() + iotDevices: IoTDevice[]; + + @OneToOne( + type => LorawanMulticastDefinition, + lorawanMulticastDefinition => lorawanMulticastDefinition.multicast, + { cascade: true } + ) + @JoinColumn() + lorawanMulticastDefinition: LorawanMulticastDefinition; +} diff --git a/src/entities/permission.entity.ts b/src/entities/permission.entity.ts index 2366f545..308dd727 100644 --- a/src/entities/permission.entity.ts +++ b/src/entities/permission.entity.ts @@ -1,8 +1,8 @@ -import { Column, Entity, ManyToMany, TableInheritance } from "typeorm"; - import { DbBaseEntity } from "@entities/base.entity"; import { User } from "@entities/user.entity"; import { PermissionType } from "@enum/permission-type.enum"; +import { Column, Entity, ManyToMany, TableInheritance } from "typeorm"; +import { ApiKey } from "./api-key.entity"; @Entity() @TableInheritance({ diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 7ebab355..852afe06 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -1,7 +1,7 @@ -import { Column, Entity, JoinTable, ManyToMany, Unique } from "typeorm"; - +import { ApiKey } from "@entities/api-key.entity"; import { DbBaseEntity } from "@entities/base.entity"; import { Permission } from "@entities/permission.entity"; +import { Column, Entity, JoinTable, ManyToMany, OneToOne, Unique } from "typeorm"; @Entity("user") @Unique(["email"]) @@ -28,4 +28,13 @@ export class User extends DbBaseEntity { @ManyToMany(type => Permission, permission => permission.users) @JoinTable() permissions: Permission[]; + + @OneToOne(type => ApiKey, a => a.systemUser, { + nullable: true, + cascade: false, + }) + apiKeyRef: ApiKey; + + @Column({ default: false }) + isSystemUser: boolean; } diff --git a/src/helpers/iot-device.helper.ts b/src/helpers/iot-device.helper.ts new file mode 100644 index 00000000..bb6ecebc --- /dev/null +++ b/src/helpers/iot-device.helper.ts @@ -0,0 +1,109 @@ +import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; +import { CreateIoTDeviceMapDto } from "@dto/iot-device/create-iot-device-map.dto"; +import { IotDeviceBatchResponseDto } from "@dto/iot-device/iot-device-batch-response.dto"; +import { UpdateIoTDeviceBatchDto } from "@dto/iot-device/update-iot-device-batch.dto"; +import { LoRaWANDeviceWithChirpstackDataDto } from "@dto/lorawan-device-with-chirpstack-data.dto"; +import { SigFoxDeviceWithBackendDataDto } from "@dto/sigfox-device-with-backend-data.dto"; +import { UpdateIoTDeviceDto } from "@dto/update-iot-device.dto"; +import { IoTDevice } from "@entities/iot-device.entity"; +import { ErrorCodes } from "@enum/error-codes.enum"; +import { checkIfUserHasWriteAccessToApplication } from "./security-helper"; + +/** + * Iterate through the devices once, splitting it into a tuple with the data we want to log + * @param response + */ +export function buildIoTDeviceCreateUpdateAuditData( + response: IotDeviceBatchResponseDto[] +): { deviceIds: number[]; deviceNames: string[] } { + return response.reduce( + (res: { deviceIds: number[]; deviceNames: string[] }, device) => { + if (!device.data || device.error) { + return res; + } + device.data.id && res.deviceIds.push(device.data.id); + device.data.name && res.deviceNames.push(device.data.name); + return res; + }, + { deviceIds: [], deviceNames: [] } + ); +} + +export function ensureUpdatePayload( + validDevices: UpdateIoTDeviceBatchDto, + oldIotDevices: ( + | IoTDevice + | LoRaWANDeviceWithChirpstackDataDto + | SigFoxDeviceWithBackendDataDto + )[], + devicesNotFound: IotDeviceBatchResponseDto[], + req: AuthenticatedRequest +): ( + previousValue: UpdateIoTDeviceDto[], + currentValue: UpdateIoTDeviceDto, + currentIndex: number, + array: UpdateIoTDeviceDto[] +) => UpdateIoTDeviceDto[] { + return (res: typeof validDevices["data"], updateDeviceDto) => { + const oldDevice = oldIotDevices.find( + oldDevice => oldDevice.id === updateDeviceDto.id + ); + + if (!oldDevice) { + devicesNotFound.push({ + idMetadata: { + applicationId: updateDeviceDto.applicationId, + name: updateDeviceDto.name, + }, + error: { + message: ErrorCodes.IdDoesNotExists, + }, + }); + return res; + } + + checkIfUserHasWriteAccessToApplication(req, oldDevice.application.id); + + if (updateDeviceDto.applicationId !== oldDevice.application.id) { + // New application + checkIfUserHasWriteAccessToApplication(req, updateDeviceDto.applicationId); + } + res.push(updateDeviceDto); + return res; + }; +} + +export function isValidIoTDeviceMap(iotDeviceMap: CreateIoTDeviceMapDto): boolean { + return !iotDeviceMap.error && !!iotDeviceMap.iotDevice; +} + +export function filterValidIotDeviceMaps( + iotDeviceMap: CreateIoTDeviceMapDto[] +): CreateIoTDeviceMapDto[] { + return iotDeviceMap.filter(map => !map.error && map.iotDevice); +} + +/** + * @param dbIotDevices + * @returns A new list of processed and failed devices + */ +export function mapAllDevicesByProcessed( + dbIotDevices: IoTDevice[] +): ( + value: CreateIoTDeviceMapDto, + index: number, + array: CreateIoTDeviceMapDto[] +) => { + data: IoTDevice; + idMetadata: { name: string; applicationId: number }; + error: Omit; +} { + return iotDeviceMap => ({ + data: dbIotDevices.find(dbDevice => dbDevice.id === iotDeviceMap.iotDevice?.id), + idMetadata: { + name: iotDeviceMap.iotDeviceDto.name, + applicationId: iotDeviceMap.iotDeviceDto.applicationId, + }, + error: iotDeviceMap.error, + }); +} diff --git a/src/helpers/optional-validator.ts b/src/helpers/optional-validator.ts new file mode 100644 index 00000000..fc8153e0 --- /dev/null +++ b/src/helpers/optional-validator.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional, ApiPropertyOptions } from "@nestjs/swagger"; +import { IsOptional } from "class-validator"; + +/** + * Sets a property as optional on the swagger and controller level + */ +export const IsSwaggerOptional = (swaggerOptions?: ApiPropertyOptions): PropertyDecorator => { + return (propertyValue: unknown, propertyName: string): void => { + // Set as optional in the swagger document + ApiPropertyOptional(swaggerOptions)(propertyValue, propertyName); + // If no value is passed, then ignore all validators + IsOptional()(propertyValue, propertyName); + }; +}; diff --git a/src/helpers/security-helper.ts b/src/helpers/security-helper.ts index ae68adb6..c31176c0 100644 --- a/src/helpers/security-helper.ts +++ b/src/helpers/security-helper.ts @@ -1,8 +1,7 @@ +import { AuthenticatedRequest } from "@entities/dto/internal/authenticated-request"; import { ForbiddenException } from "@nestjs/common"; import * as _ from "lodash"; -import { AuthenticatedRequest } from "@entities/dto/internal/authenticated-request"; - export function checkIfUserHasWriteAccessToApplication( req: AuthenticatedRequest, applicationId: number @@ -60,6 +59,44 @@ export function checkIfUserHasAdminAccessToOrganization( ); } +// Checks if the user has admin access to ANY of the supplied organizations +export function checkIfUserHasAdminAccessToAnyOrganization( + req: AuthenticatedRequest, + organisationIds: number[] +): void { + if (req.user.permissions.isGlobalAdmin) { + return; + } + + const userAdminOrganizations = req.user.permissions.getAllOrganizationsWithAtLeastAdmin(); + + for (const id of organisationIds) { + if (_.includes(userAdminOrganizations, id)) { + return; + } + } + + throw new ForbiddenException(); +} + +// Checks if the user has admin access to ALL of the supplied organizations +export function checkIfUserHasAdminAccessToAllOrganizations( + req: AuthenticatedRequest, + organisationIds: number[] +): void { + if (req.user.permissions.isGlobalAdmin) { + return; + } + + const userAdminOrganizations = req.user.permissions.getAllOrganizationsWithAtLeastAdmin(); + + for (const id of organisationIds) { + if (!_.includes(userAdminOrganizations, id)) { + throw new ForbiddenException(); + } + } +} + export function checkIfUserIsGlobalAdmin(req: AuthenticatedRequest): void { if (!req.user.permissions.isGlobalAdmin) { throw new ForbiddenException(); diff --git a/src/helpers/string-to-number-validator.ts b/src/helpers/string-to-number-validator.ts new file mode 100644 index 00000000..721e7298 --- /dev/null +++ b/src/helpers/string-to-number-validator.ts @@ -0,0 +1,14 @@ +import { Type } from "class-transformer"; +import { IsNumber } from "class-validator"; + +/** + * Checks if a value can be converted to a number + */ +export const StringToNumber = (): PropertyDecorator => { + return (propertyValue: unknown, propertyName: string): void => { + // Cast the value to a number + Type(() => Number)(propertyValue, propertyName); + // Validate whether the value is a number + IsNumber()(propertyValue, propertyName); + }; +}; diff --git a/src/helpers/type-helper.ts b/src/helpers/type-helper.ts new file mode 100644 index 00000000..cdf585c3 --- /dev/null +++ b/src/helpers/type-helper.ts @@ -0,0 +1 @@ +export const nameof = (name: Extract): string => name; diff --git a/src/loaders/nestjs.ts b/src/loaders/nestjs.ts index 243cb7b0..c32500b2 100644 --- a/src/loaders/nestjs.ts +++ b/src/loaders/nestjs.ts @@ -28,8 +28,12 @@ export async function setupNestJs( app.useGlobalPipes( new ValidationPipe({ exceptionFactory: errors => { + // Throw exception if any controller validation fails. Will also fail if a property has a type + // but doesn't have the proper decorator (like @IsNumber() for a number property) return new BadRequestException(errors); }, + // Fix CVE-2019-18413. Issue: https://github.com/typestack/class-validator/issues/438 + forbidUnknownValues: true, }) ); app.enableCors(); diff --git a/src/migration/1642075074141-multicast.ts b/src/migration/1642075074141-multicast.ts new file mode 100644 index 00000000..b3a512b1 --- /dev/null +++ b/src/migration/1642075074141-multicast.ts @@ -0,0 +1,48 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class multicast1642075074141 implements MigrationInterface { + name = 'multicast1642075074141' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "lorawan-multicast" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "address" character varying NOT NULL, "networkSessionKey" character varying NOT NULL, "applicationSessionKey" character varying NOT NULL, "frameCounter" integer NOT NULL, "dataRate" integer NOT NULL, "frequency" integer NOT NULL, "groupType" character varying NOT NULL, "chirpstackGroupId" character varying, "createdById" integer, "updatedById" integer, CONSTRAINT "PK_3a1533b403f6e38b0ec4df5aee5" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "multicast" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "groupName" character varying NOT NULL, "createdById" integer, "updatedById" integer, "applicationId" integer, "lorawanMulticastDefinitionId" integer, CONSTRAINT "REL_5a058ff45da16000d4e3b409e3" UNIQUE ("lorawanMulticastDefinitionId"), CONSTRAINT "PK_5c305350f804318120f45107a64" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "multicast_iot_devices_iot_device" ("multicastId" integer NOT NULL, "iotDeviceId" integer NOT NULL, CONSTRAINT "PK_281c23f69718bec32d05cd8398c" PRIMARY KEY ("multicastId", "iotDeviceId"))`); + await queryRunner.query(`CREATE INDEX "IDX_f502bc0bd3c1406ae2b3dc1562" ON "multicast_iot_devices_iot_device" ("multicastId") `); + await queryRunner.query(`CREATE INDEX "IDX_0c5a137689ddc88c8257e2cd46" ON "multicast_iot_devices_iot_device" ("iotDeviceId") `); + await queryRunner.query(`CREATE TABLE "iot_device_multicasts_multicast" ("iotDeviceId" integer NOT NULL, "multicastId" integer NOT NULL, CONSTRAINT "PK_d3186bb9eaa001bf2ea3ccefa37" PRIMARY KEY ("iotDeviceId", "multicastId"))`); + await queryRunner.query(`CREATE INDEX "IDX_9b91f8a9dcc02c5926fced99e1" ON "iot_device_multicasts_multicast" ("iotDeviceId") `); + await queryRunner.query(`CREATE INDEX "IDX_d38b5d829712b4e0df2bae160f" ON "iot_device_multicasts_multicast" ("multicastId") `); + await queryRunner.query(`ALTER TABLE "lorawan-multicast" ADD CONSTRAINT "FK_bfb223faca94d9e217c39439c49" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "lorawan-multicast" ADD CONSTRAINT "FK_c776cdd3a4b1818c0995ee60baa" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "multicast" ADD CONSTRAINT "FK_60c29583cfbafbd6b0e1a9679c9" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "multicast" ADD CONSTRAINT "FK_0000d423c968ec8ba68482f8b0b" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "multicast" ADD CONSTRAINT "FK_ea6c184aa80e1f16cc8edb8e743" FOREIGN KEY ("applicationId") REFERENCES "application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "multicast" ADD CONSTRAINT "FK_5a058ff45da16000d4e3b409e36" FOREIGN KEY ("lorawanMulticastDefinitionId") REFERENCES "lorawan-multicast"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "multicast_iot_devices_iot_device" ADD CONSTRAINT "FK_f502bc0bd3c1406ae2b3dc15628" FOREIGN KEY ("multicastId") REFERENCES "multicast"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "multicast_iot_devices_iot_device" ADD CONSTRAINT "FK_0c5a137689ddc88c8257e2cd46f" FOREIGN KEY ("iotDeviceId") REFERENCES "iot_device"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_device_multicasts_multicast" ADD CONSTRAINT "FK_9b91f8a9dcc02c5926fced99e1b" FOREIGN KEY ("iotDeviceId") REFERENCES "iot_device"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_device_multicasts_multicast" ADD CONSTRAINT "FK_d38b5d829712b4e0df2bae160f4" FOREIGN KEY ("multicastId") REFERENCES "multicast"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "iot_device_multicasts_multicast" DROP CONSTRAINT "FK_d38b5d829712b4e0df2bae160f4"`); + await queryRunner.query(`ALTER TABLE "iot_device_multicasts_multicast" DROP CONSTRAINT "FK_9b91f8a9dcc02c5926fced99e1b"`); + await queryRunner.query(`ALTER TABLE "multicast_iot_devices_iot_device" DROP CONSTRAINT "FK_0c5a137689ddc88c8257e2cd46f"`); + await queryRunner.query(`ALTER TABLE "multicast_iot_devices_iot_device" DROP CONSTRAINT "FK_f502bc0bd3c1406ae2b3dc15628"`); + await queryRunner.query(`ALTER TABLE "multicast" DROP CONSTRAINT "FK_5a058ff45da16000d4e3b409e36"`); + await queryRunner.query(`ALTER TABLE "multicast" DROP CONSTRAINT "FK_ea6c184aa80e1f16cc8edb8e743"`); + await queryRunner.query(`ALTER TABLE "multicast" DROP CONSTRAINT "FK_0000d423c968ec8ba68482f8b0b"`); + await queryRunner.query(`ALTER TABLE "multicast" DROP CONSTRAINT "FK_60c29583cfbafbd6b0e1a9679c9"`); + await queryRunner.query(`ALTER TABLE "lorawan-multicast" DROP CONSTRAINT "FK_c776cdd3a4b1818c0995ee60baa"`); + await queryRunner.query(`ALTER TABLE "lorawan-multicast" DROP CONSTRAINT "FK_bfb223faca94d9e217c39439c49"`); + await queryRunner.query(`DROP INDEX "IDX_d38b5d829712b4e0df2bae160f"`); + await queryRunner.query(`DROP INDEX "IDX_9b91f8a9dcc02c5926fced99e1"`); + await queryRunner.query(`DROP TABLE "iot_device_multicasts_multicast"`); + await queryRunner.query(`DROP INDEX "IDX_0c5a137689ddc88c8257e2cd46"`); + await queryRunner.query(`DROP INDEX "IDX_f502bc0bd3c1406ae2b3dc1562"`); + await queryRunner.query(`DROP TABLE "multicast_iot_devices_iot_device"`); + await queryRunner.query(`DROP TABLE "multicast"`); + await queryRunner.query(`DROP TABLE "lorawan-multicast"`); + } + +} diff --git a/src/migration/1642683695610-ApiKey.ts b/src/migration/1642683695610-ApiKey.ts new file mode 100644 index 00000000..22566329 --- /dev/null +++ b/src/migration/1642683695610-ApiKey.ts @@ -0,0 +1,42 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class apiKey1642683695610 implements MigrationInterface { + name = 'apiKey1642683695610' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "api_key" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "key" character varying NOT NULL, "name" character varying NOT NULL, "createdById" integer, "updatedById" integer, "systemUserId" integer NOT NULL, CONSTRAINT "UQ_fb080786c16de6ace7ed0b69f7d" UNIQUE ("key"), CONSTRAINT "REL_ba0ccb6c48e13f3fe1ff78f3d2" UNIQUE ("systemUserId"), CONSTRAINT "PK_b1bd840641b8acbaad89c3d8d11" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "api_key_permissions_permission" ("apiKeyId" integer NOT NULL, "permissionId" integer NOT NULL, CONSTRAINT "PK_61ad0f8361cce05de5ea769351a" PRIMARY KEY ("apiKeyId", "permissionId"))`); + await queryRunner.query(`CREATE INDEX "IDX_c1141b0748c24b2f3e78789b6c" ON "api_key_permissions_permission" ("apiKeyId") `); + await queryRunner.query(`CREATE INDEX "IDX_a77f5c848b7b502da526075eb5" ON "api_key_permissions_permission" ("permissionId") `); + await queryRunner.query(`ALTER TABLE "user" ADD "isSystemUser" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TYPE "public"."permission_type_enum" RENAME TO "permission_type_enum_old"`); + await queryRunner.query(`CREATE TYPE "permission_type_enum" AS ENUM('GlobalAdmin', 'OrganizationAdmin', 'Write', 'Read', 'OrganizationPermission', 'OrganizationApplicationPermissions', 'ApiKeyPermission')`); + await queryRunner.query(`ALTER TABLE "permission" ALTER COLUMN "type" TYPE "permission_type_enum" USING "type"::"text"::"permission_type_enum"`); + await queryRunner.query(`DROP TYPE "permission_type_enum_old"`); + await queryRunner.query(`COMMENT ON COLUMN "permission"."type" IS NULL`); + await queryRunner.query(`ALTER TABLE "api_key" ADD CONSTRAINT "FK_76c1592a8ca784b7b66edfa35d2" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "api_key" ADD CONSTRAINT "FK_61eb6b84e8a7efb8617c28c5f1c" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "api_key" ADD CONSTRAINT "FK_ba0ccb6c48e13f3fe1ff78f3d24" FOREIGN KEY ("systemUserId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "api_key_permissions_permission" ADD CONSTRAINT "FK_c1141b0748c24b2f3e78789b6c8" FOREIGN KEY ("apiKeyId") REFERENCES "api_key"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "api_key_permissions_permission" ADD CONSTRAINT "FK_a77f5c848b7b502da526075eb58" FOREIGN KEY ("permissionId") REFERENCES "permission"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "api_key_permissions_permission" DROP CONSTRAINT "FK_a77f5c848b7b502da526075eb58"`); + await queryRunner.query(`ALTER TABLE "api_key_permissions_permission" DROP CONSTRAINT "FK_c1141b0748c24b2f3e78789b6c8"`); + await queryRunner.query(`ALTER TABLE "api_key" DROP CONSTRAINT "FK_ba0ccb6c48e13f3fe1ff78f3d24"`); + await queryRunner.query(`ALTER TABLE "api_key" DROP CONSTRAINT "FK_61eb6b84e8a7efb8617c28c5f1c"`); + await queryRunner.query(`ALTER TABLE "api_key" DROP CONSTRAINT "FK_76c1592a8ca784b7b66edfa35d2"`); + await queryRunner.query(`COMMENT ON COLUMN "permission"."type" IS NULL`); + await queryRunner.query(`CREATE TYPE "permission_type_enum_old" AS ENUM('GlobalAdmin', 'OrganizationAdmin', 'Write', 'Read', 'OrganizationPermission', 'OrganizationApplicationPermissions')`); + await queryRunner.query(`ALTER TABLE "permission" ALTER COLUMN "type" TYPE "permission_type_enum_old" USING "type"::"text"::"permission_type_enum_old"`); + await queryRunner.query(`DROP TYPE "permission_type_enum"`); + await queryRunner.query(`ALTER TYPE "permission_type_enum_old" RENAME TO "permission_type_enum"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isSystemUser"`); + await queryRunner.query(`DROP INDEX "IDX_a77f5c848b7b502da526075eb5"`); + await queryRunner.query(`DROP INDEX "IDX_c1141b0748c24b2f3e78789b6c"`); + await queryRunner.query(`DROP TABLE "api_key_permissions_permission"`); + await queryRunner.query(`DROP TABLE "api_key"`); + } + +} diff --git a/src/migration/1644874601012-fiware-data-target.ts b/src/migration/1644874601012-fiware-data-target.ts new file mode 100644 index 00000000..dc958cfd --- /dev/null +++ b/src/migration/1644874601012-fiware-data-target.ts @@ -0,0 +1,26 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class fiwareDataTarget1644874601012 implements MigrationInterface { + name = 'fiwareDataTarget1644874601012' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "data_target" ADD "tenant" character varying`); + await queryRunner.query(`ALTER TABLE "data_target" ADD "context" character varying`); + await queryRunner.query(`ALTER TYPE "public"."data_target_type_enum" RENAME TO "data_target_type_enum_old"`); + await queryRunner.query(`CREATE TYPE "data_target_type_enum" AS ENUM('HTTP_PUSH', 'FIWARE')`); + await queryRunner.query(`ALTER TABLE "data_target" ALTER COLUMN "type" TYPE "data_target_type_enum" USING "type"::"text"::"data_target_type_enum"`); + await queryRunner.query(`DROP TYPE "data_target_type_enum_old"`); + await queryRunner.query(`COMMENT ON COLUMN "data_target"."type" IS NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`COMMENT ON COLUMN "data_target"."type" IS NULL`); + await queryRunner.query(`CREATE TYPE "data_target_type_enum_old" AS ENUM('HTTP_PUSH')`); + await queryRunner.query(`ALTER TABLE "data_target" ALTER COLUMN "type" TYPE "data_target_type_enum_old" USING "type"::"text"::"data_target_type_enum_old"`); + await queryRunner.query(`DROP TYPE "data_target_type_enum"`); + await queryRunner.query(`ALTER TYPE "data_target_type_enum_old" RENAME TO "data_target_type_enum"`); + await queryRunner.query(`ALTER TABLE "data_target" DROP COLUMN "context"`); + await queryRunner.query(`ALTER TABLE "data_target" DROP COLUMN "tenant"`); + } + +} diff --git a/src/migration/initial.ts b/src/migration/initial.ts new file mode 100644 index 00000000..9c407832 --- /dev/null +++ b/src/migration/initial.ts @@ -0,0 +1,168 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class Initial0000000000001 implements MigrationInterface { + name = 'Initial0000000000001' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "open_data_dk_dataset" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying NOT NULL, "description" character varying NOT NULL DEFAULT '', "keywords" text array, "license" character varying NOT NULL, "authorName" character varying NOT NULL, "authorEmail" character varying NOT NULL, "resourceTitle" character varying NOT NULL DEFAULT '', "createdById" integer, "updatedById" integer, "dataTargetId" integer, CONSTRAINT "REL_a0b7439e2a06cd0b67c4486a4f" UNIQUE ("dataTargetId"), CONSTRAINT "PK_07c9a82d4ee55c1207a2ce17731" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "received_message" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "rawData" jsonb NOT NULL, "sentTime" TIMESTAMP NOT NULL, "createdById" integer, "updatedById" integer, "deviceId" integer, CONSTRAINT "REL_b0a8014e917afd2b1152c83dc1" UNIQUE ("deviceId"), CONSTRAINT "PK_0bd1cfe05dbb3bc22d0abbcf7ac" PRIMARY KEY ("id")); COMMENT ON COLUMN "received_message"."sentTime" IS 'Time reported by device (if possible, otherwise time received)'`); + await queryRunner.query(`CREATE TABLE "received_message_metadata" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "sentTime" TIMESTAMP NOT NULL, "signalData" jsonb, "createdById" integer, "updatedById" integer, "deviceId" integer, CONSTRAINT "PK_95d11e96204b0a42da220970411" PRIMARY KEY ("id")); COMMENT ON COLUMN "received_message_metadata"."sentTime" IS 'Time reported by device (if possible, otherwise time received)'`); + await queryRunner.query(`CREATE INDEX "IDX_4e710f4b84fd89dbbbab4616bf" ON "received_message_metadata" ("deviceId", "sentTime") `); + await queryRunner.query(`CREATE TABLE "user" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying NOT NULL, "email" character varying, "passwordHash" character varying, "active" boolean NOT NULL DEFAULT true, "lastLogin" TIMESTAMP, "nameId" character varying, "createdById" integer, "updatedById" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TYPE "permission_type_enum" AS ENUM('GlobalAdmin', 'OrganizationAdmin', 'Write', 'Read', 'OrganizationPermission', 'OrganizationApplicationPermissions')`); + await queryRunner.query(`CREATE TABLE "permission" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "type" "permission_type_enum" NOT NULL, "name" character varying NOT NULL, "automaticallyAddNewApplications" boolean DEFAULT false, "createdById" integer, "updatedById" integer, "organizationId" integer, CONSTRAINT "PK_3b8b97af9d9d8807e41e6f48362" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_71bf2818fb2ad92e208d7aeadf" ON "permission" ("type") `); + await queryRunner.query(`CREATE TABLE "payload_decoder" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying NOT NULL, "decodingFunction" text NOT NULL, "createdById" integer, "updatedById" integer, "organizationId" integer, CONSTRAINT "PK_04d515ff430849ef550db2f4495" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "sigfox_group" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "username" character varying NOT NULL, "password" character varying NOT NULL, "sigfoxGroupId" character varying NOT NULL DEFAULT '', "createdById" integer, "updatedById" integer, "belongsToId" integer, CONSTRAINT "UQ_c658773871b8ec5ad9f9f4f692d" UNIQUE ("username", "belongsToId"), CONSTRAINT "PK_d4573623c283131a7901097798d" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "organization" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying NOT NULL, "createdById" integer, "updatedById" integer, CONSTRAINT "UQ_c21e615583a3ebbb0977452afb0" UNIQUE ("name"), CONSTRAINT "UQ_c21e615583a3ebbb0977452afb0" UNIQUE ("name"), CONSTRAINT "PK_472c1f99a32def1b0abb219cd67" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "device_model" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "body" jsonb, "createdById" integer, "updatedById" integer, "belongsToId" integer, CONSTRAINT "PK_0ad50a03f4778f2d1dc83386a77" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TYPE "iot_device_type_enum" AS ENUM('GENERIC_HTTP', 'LORAWAN', 'SIGFOX')`); + await queryRunner.query(`CREATE TABLE "iot_device" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying NOT NULL, "location" geometry(Point,4326), "commentOnLocation" character varying, "comment" character varying, "metadata" jsonb, "type" "iot_device_type_enum" NOT NULL, "apiKey" character varying, "deviceEUI" character varying, "chirpstackApplicationId" integer, "deviceId" character varying, "deviceTypeId" character varying, "groupId" character varying, "downlinkPayload" character varying, "createdById" integer, "updatedById" integer, "applicationId" integer, "deviceModelId" integer, CONSTRAINT "PK_9ebe9e6bfa9a631bfba27b3d57d" PRIMARY KEY ("id")); COMMENT ON COLUMN "iot_device"."apiKey" IS 'Used for GenericHTTPDevice'`); + await queryRunner.query(`CREATE INDEX "IDX_18ce5007f4efcbe92338c10f5d" ON "iot_device" ("type") `); + await queryRunner.query(`CREATE TABLE "iot_device_payload_decoder_data_target_connection" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "createdById" integer, "updatedById" integer, "payloadDecoderId" integer, "dataTargetId" integer, CONSTRAINT "PK_a0470515beb1c8087c4e868bf06" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_7055bf87cc824d4d1268704818" ON "iot_device_payload_decoder_data_target_connection" ("dataTargetId") `); + await queryRunner.query(`CREATE INDEX "IDX_1ef58c35ae5562242966d483a1" ON "iot_device_payload_decoder_data_target_connection" ("payloadDecoderId") `); + await queryRunner.query(`CREATE TYPE "data_target_type_enum" AS ENUM('HTTP_PUSH')`); + await queryRunner.query(`CREATE TABLE "data_target" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "type" "data_target_type_enum" NOT NULL, "name" character varying NOT NULL, "url" character varying, "timeout" integer DEFAULT '30000', "authorizationHeader" character varying, "createdById" integer, "updatedById" integer, "applicationId" integer, CONSTRAINT "PK_cd4e902e1acce0793eeba6d0dc5" PRIMARY KEY ("id")); COMMENT ON COLUMN "data_target"."timeout" IS 'HTTP call timeout in milliseconds'`); + await queryRunner.query(`CREATE INDEX "IDX_af951594386c64a22f8fc057be" ON "data_target" ("type") `); + await queryRunner.query(`CREATE TABLE "application" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying NOT NULL, "description" character varying, "createdById" integer, "updatedById" integer, "belongsToId" integer, CONSTRAINT "UQ_608bb41e7e1ef5f6d7abb07e394" UNIQUE ("name"), CONSTRAINT "PK_569e0c3e863ebdf5f2408ee1670" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "user_permissions_permission" ("userId" integer NOT NULL, "permissionId" integer NOT NULL, CONSTRAINT "PK_8dd49853fbad35f9a0f91b11877" PRIMARY KEY ("userId", "permissionId"))`); + await queryRunner.query(`CREATE INDEX "IDX_5b72d197d92b8bafbe7906782e" ON "user_permissions_permission" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_c43a6a56e3ef281cbfba9a7745" ON "user_permissions_permission" ("permissionId") `); + await queryRunner.query(`CREATE TABLE "iot_dev_pay_dec_dat_tar_con_iot_dev_iot_dev" ("iotDevicePayloadDecoderDataTargetConnectionId" integer NOT NULL, "iotDeviceId" integer NOT NULL, CONSTRAINT "PK_f03f957266ca9997d3f492ed018" PRIMARY KEY ("iotDevicePayloadDecoderDataTargetConnectionId", "iotDeviceId"))`); + await queryRunner.query(`CREATE INDEX "IDX_daf134b834f403ea98efa1fc09" ON "iot_dev_pay_dec_dat_tar_con_iot_dev_iot_dev" ("iotDevicePayloadDecoderDataTargetConnectionId") `); + await queryRunner.query(`CREATE INDEX "IDX_c88fdc6da057254fe8f9e15555" ON "iot_dev_pay_dec_dat_tar_con_iot_dev_iot_dev" ("iotDeviceId") `); + await queryRunner.query(`CREATE TABLE "application_permissions_permission" ("applicationId" integer NOT NULL, "permissionId" integer NOT NULL, CONSTRAINT "PK_27688b5488c8a5b7fc415663a17" PRIMARY KEY ("applicationId", "permissionId"))`); + await queryRunner.query(`CREATE INDEX "IDX_6c691b1ba972915dc7bf324420" ON "application_permissions_permission" ("applicationId") `); + await queryRunner.query(`CREATE INDEX "IDX_c1bbb34687ca84f2a166ee376e" ON "application_permissions_permission" ("permissionId") `); + await queryRunner.query(`ALTER TABLE "open_data_dk_dataset" ADD CONSTRAINT "FK_981af5bddacc8b7bf9f37247fb6" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "open_data_dk_dataset" ADD CONSTRAINT "FK_d5b350d2e5efe0229d8e01f7507" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "open_data_dk_dataset" ADD CONSTRAINT "FK_a0b7439e2a06cd0b67c4486a4f7" FOREIGN KEY ("dataTargetId") REFERENCES "data_target"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "received_message" ADD CONSTRAINT "FK_38b28c3a0241fcec40207e0745d" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "received_message" ADD CONSTRAINT "FK_21ec54fded11c9d4ee8df3c17dd" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "received_message" ADD CONSTRAINT "FK_b0a8014e917afd2b1152c83dc10" FOREIGN KEY ("deviceId") REFERENCES "iot_device"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "received_message_metadata" ADD CONSTRAINT "FK_4834b1a60043b2c443b07c08b17" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "received_message_metadata" ADD CONSTRAINT "FK_8399e14dc77145a32ccb755a84d" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "received_message_metadata" ADD CONSTRAINT "FK_de1719d0660b369de300440ad6f" FOREIGN KEY ("deviceId") REFERENCES "iot_device"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_45c0d39d1f9ceeb56942db93cc5" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_db5173f7d27aa8a98a9fe6113df" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "permission" ADD CONSTRAINT "FK_00e2c09abd157b5358faf3f43d0" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "permission" ADD CONSTRAINT "FK_40c4877af6e402a449d56af4d39" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "permission" ADD CONSTRAINT "FK_2102b10c8a5424189ac612ca8d9" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payload_decoder" ADD CONSTRAINT "FK_33f98e074272079c02d792aa852" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payload_decoder" ADD CONSTRAINT "FK_ce6313b2fa3de41f2d4d9409003" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payload_decoder" ADD CONSTRAINT "FK_20c47c4f5719e0d398743401acd" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "sigfox_group" ADD CONSTRAINT "FK_f5401745b31e0eedff2d219f77d" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "sigfox_group" ADD CONSTRAINT "FK_23d9047d051ab1ad8875306ae98" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "sigfox_group" ADD CONSTRAINT "FK_a38129f018eb963aea886de12e9" FOREIGN KEY ("belongsToId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "organization" ADD CONSTRAINT "FK_acdbd1e490930af04b4ff569ca9" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "organization" ADD CONSTRAINT "FK_a7a6b96e8460aeb314599cf3580" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "device_model" ADD CONSTRAINT "FK_9d26f066b617bbdac41d72e82b2" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "device_model" ADD CONSTRAINT "FK_ed6727d1c4a7f9e6299ba6e2961" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "device_model" ADD CONSTRAINT "FK_75a9d1c5495facf583614ca2b5f" FOREIGN KEY ("belongsToId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_device" ADD CONSTRAINT "FK_36879bbabdd338403df3639ebf7" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_device" ADD CONSTRAINT "FK_a15ceb5dd1d6c1eff4bccddc513" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_device" ADD CONSTRAINT "FK_3e4f8964a797bd27b29a72a4718" FOREIGN KEY ("applicationId") REFERENCES "application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_device" ADD CONSTRAINT "FK_bd1fee6c283fe1db22aeed6ac36" FOREIGN KEY ("deviceModelId") REFERENCES "device_model"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_device_payload_decoder_data_target_connection" ADD CONSTRAINT "FK_e1e5066f7ec7093fe51b9a84eca" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_device_payload_decoder_data_target_connection" ADD CONSTRAINT "FK_dfe8b280f6a586177f7f2badc2f" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_device_payload_decoder_data_target_connection" ADD CONSTRAINT "FK_1ef58c35ae5562242966d483a16" FOREIGN KEY ("payloadDecoderId") REFERENCES "payload_decoder"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_device_payload_decoder_data_target_connection" ADD CONSTRAINT "FK_7055bf87cc824d4d1268704818b" FOREIGN KEY ("dataTargetId") REFERENCES "data_target"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "data_target" ADD CONSTRAINT "FK_062e2d7b403e1d9736d3a26c86e" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "data_target" ADD CONSTRAINT "FK_aec53ab5541644651b38921719d" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "data_target" ADD CONSTRAINT "FK_dc5681eea8037a19634c4630057" FOREIGN KEY ("applicationId") REFERENCES "application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "application" ADD CONSTRAINT "FK_d7021375eb0ef5d648641b78886" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "application" ADD CONSTRAINT "FK_faa0fa6bed13e319c54ad6c4636" FOREIGN KEY ("updatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "application" ADD CONSTRAINT "FK_7eb7ebc6eb5a830b9fb78030a8e" FOREIGN KEY ("belongsToId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_permissions_permission" ADD CONSTRAINT "FK_5b72d197d92b8bafbe7906782ec" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_permissions_permission" ADD CONSTRAINT "FK_c43a6a56e3ef281cbfba9a77457" FOREIGN KEY ("permissionId") REFERENCES "permission"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_dev_pay_dec_dat_tar_con_iot_dev_iot_dev" ADD CONSTRAINT "FK_daf134b834f403ea98efa1fc09f" FOREIGN KEY ("iotDevicePayloadDecoderDataTargetConnectionId") REFERENCES "iot_device_payload_decoder_data_target_connection"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "iot_dev_pay_dec_dat_tar_con_iot_dev_iot_dev" ADD CONSTRAINT "FK_c88fdc6da057254fe8f9e155559" FOREIGN KEY ("iotDeviceId") REFERENCES "iot_device"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "application_permissions_permission" ADD CONSTRAINT "FK_6c691b1ba972915dc7bf3244204" FOREIGN KEY ("applicationId") REFERENCES "application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "application_permissions_permission" ADD CONSTRAINT "FK_c1bbb34687ca84f2a166ee376e2" FOREIGN KEY ("permissionId") REFERENCES "permission"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "application_permissions_permission" DROP CONSTRAINT "FK_c1bbb34687ca84f2a166ee376e2"`); + await queryRunner.query(`ALTER TABLE "application_permissions_permission" DROP CONSTRAINT "FK_6c691b1ba972915dc7bf3244204"`); + await queryRunner.query(`ALTER TABLE "iot_dev_pay_dec_dat_tar_con_iot_dev_iot_dev" DROP CONSTRAINT "FK_c88fdc6da057254fe8f9e155559"`); + await queryRunner.query(`ALTER TABLE "iot_dev_pay_dec_dat_tar_con_iot_dev_iot_dev" DROP CONSTRAINT "FK_daf134b834f403ea98efa1fc09f"`); + await queryRunner.query(`ALTER TABLE "user_permissions_permission" DROP CONSTRAINT "FK_c43a6a56e3ef281cbfba9a77457"`); + await queryRunner.query(`ALTER TABLE "user_permissions_permission" DROP CONSTRAINT "FK_5b72d197d92b8bafbe7906782ec"`); + await queryRunner.query(`ALTER TABLE "application" DROP CONSTRAINT "FK_7eb7ebc6eb5a830b9fb78030a8e"`); + await queryRunner.query(`ALTER TABLE "application" DROP CONSTRAINT "FK_faa0fa6bed13e319c54ad6c4636"`); + await queryRunner.query(`ALTER TABLE "application" DROP CONSTRAINT "FK_d7021375eb0ef5d648641b78886"`); + await queryRunner.query(`ALTER TABLE "data_target" DROP CONSTRAINT "FK_dc5681eea8037a19634c4630057"`); + await queryRunner.query(`ALTER TABLE "data_target" DROP CONSTRAINT "FK_aec53ab5541644651b38921719d"`); + await queryRunner.query(`ALTER TABLE "data_target" DROP CONSTRAINT "FK_062e2d7b403e1d9736d3a26c86e"`); + await queryRunner.query(`ALTER TABLE "iot_device_payload_decoder_data_target_connection" DROP CONSTRAINT "FK_7055bf87cc824d4d1268704818b"`); + await queryRunner.query(`ALTER TABLE "iot_device_payload_decoder_data_target_connection" DROP CONSTRAINT "FK_1ef58c35ae5562242966d483a16"`); + await queryRunner.query(`ALTER TABLE "iot_device_payload_decoder_data_target_connection" DROP CONSTRAINT "FK_dfe8b280f6a586177f7f2badc2f"`); + await queryRunner.query(`ALTER TABLE "iot_device_payload_decoder_data_target_connection" DROP CONSTRAINT "FK_e1e5066f7ec7093fe51b9a84eca"`); + await queryRunner.query(`ALTER TABLE "iot_device" DROP CONSTRAINT "FK_bd1fee6c283fe1db22aeed6ac36"`); + await queryRunner.query(`ALTER TABLE "iot_device" DROP CONSTRAINT "FK_3e4f8964a797bd27b29a72a4718"`); + await queryRunner.query(`ALTER TABLE "iot_device" DROP CONSTRAINT "FK_a15ceb5dd1d6c1eff4bccddc513"`); + await queryRunner.query(`ALTER TABLE "iot_device" DROP CONSTRAINT "FK_36879bbabdd338403df3639ebf7"`); + await queryRunner.query(`ALTER TABLE "device_model" DROP CONSTRAINT "FK_75a9d1c5495facf583614ca2b5f"`); + await queryRunner.query(`ALTER TABLE "device_model" DROP CONSTRAINT "FK_ed6727d1c4a7f9e6299ba6e2961"`); + await queryRunner.query(`ALTER TABLE "device_model" DROP CONSTRAINT "FK_9d26f066b617bbdac41d72e82b2"`); + await queryRunner.query(`ALTER TABLE "organization" DROP CONSTRAINT "FK_a7a6b96e8460aeb314599cf3580"`); + await queryRunner.query(`ALTER TABLE "organization" DROP CONSTRAINT "FK_acdbd1e490930af04b4ff569ca9"`); + await queryRunner.query(`ALTER TABLE "sigfox_group" DROP CONSTRAINT "FK_a38129f018eb963aea886de12e9"`); + await queryRunner.query(`ALTER TABLE "sigfox_group" DROP CONSTRAINT "FK_23d9047d051ab1ad8875306ae98"`); + await queryRunner.query(`ALTER TABLE "sigfox_group" DROP CONSTRAINT "FK_f5401745b31e0eedff2d219f77d"`); + await queryRunner.query(`ALTER TABLE "payload_decoder" DROP CONSTRAINT "FK_20c47c4f5719e0d398743401acd"`); + await queryRunner.query(`ALTER TABLE "payload_decoder" DROP CONSTRAINT "FK_ce6313b2fa3de41f2d4d9409003"`); + await queryRunner.query(`ALTER TABLE "payload_decoder" DROP CONSTRAINT "FK_33f98e074272079c02d792aa852"`); + await queryRunner.query(`ALTER TABLE "permission" DROP CONSTRAINT "FK_2102b10c8a5424189ac612ca8d9"`); + await queryRunner.query(`ALTER TABLE "permission" DROP CONSTRAINT "FK_40c4877af6e402a449d56af4d39"`); + await queryRunner.query(`ALTER TABLE "permission" DROP CONSTRAINT "FK_00e2c09abd157b5358faf3f43d0"`); + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_db5173f7d27aa8a98a9fe6113df"`); + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_45c0d39d1f9ceeb56942db93cc5"`); + await queryRunner.query(`ALTER TABLE "received_message_metadata" DROP CONSTRAINT "FK_de1719d0660b369de300440ad6f"`); + await queryRunner.query(`ALTER TABLE "received_message_metadata" DROP CONSTRAINT "FK_8399e14dc77145a32ccb755a84d"`); + await queryRunner.query(`ALTER TABLE "received_message_metadata" DROP CONSTRAINT "FK_4834b1a60043b2c443b07c08b17"`); + await queryRunner.query(`ALTER TABLE "received_message" DROP CONSTRAINT "FK_b0a8014e917afd2b1152c83dc10"`); + await queryRunner.query(`ALTER TABLE "received_message" DROP CONSTRAINT "FK_21ec54fded11c9d4ee8df3c17dd"`); + await queryRunner.query(`ALTER TABLE "received_message" DROP CONSTRAINT "FK_38b28c3a0241fcec40207e0745d"`); + await queryRunner.query(`ALTER TABLE "open_data_dk_dataset" DROP CONSTRAINT "FK_a0b7439e2a06cd0b67c4486a4f7"`); + await queryRunner.query(`ALTER TABLE "open_data_dk_dataset" DROP CONSTRAINT "FK_d5b350d2e5efe0229d8e01f7507"`); + await queryRunner.query(`ALTER TABLE "open_data_dk_dataset" DROP CONSTRAINT "FK_981af5bddacc8b7bf9f37247fb6"`); + await queryRunner.query(`DROP INDEX "IDX_c1bbb34687ca84f2a166ee376e"`); + await queryRunner.query(`DROP INDEX "IDX_6c691b1ba972915dc7bf324420"`); + await queryRunner.query(`DROP TABLE "application_permissions_permission"`); + await queryRunner.query(`DROP INDEX "IDX_c88fdc6da057254fe8f9e15555"`); + await queryRunner.query(`DROP INDEX "IDX_daf134b834f403ea98efa1fc09"`); + await queryRunner.query(`DROP TABLE "iot_dev_pay_dec_dat_tar_con_iot_dev_iot_dev"`); + await queryRunner.query(`DROP INDEX "IDX_d38b5d829712b4e0df2bae160f"`); + await queryRunner.query(`DROP INDEX "IDX_9b91f8a9dcc02c5926fced99e1"`); + await queryRunner.query(`DROP INDEX "IDX_0c5a137689ddc88c8257e2cd46"`); + await queryRunner.query(`DROP INDEX "IDX_f502bc0bd3c1406ae2b3dc1562"`); + await queryRunner.query(`DROP INDEX "IDX_c43a6a56e3ef281cbfba9a7745"`); + await queryRunner.query(`DROP INDEX "IDX_5b72d197d92b8bafbe7906782e"`); + await queryRunner.query(`DROP TABLE "user_permissions_permission"`); + await queryRunner.query(`DROP TABLE "application"`); + await queryRunner.query(`DROP INDEX "IDX_af951594386c64a22f8fc057be"`); + await queryRunner.query(`DROP TABLE "data_target"`); + await queryRunner.query(`DROP TYPE "data_target_type_enum"`); + await queryRunner.query(`DROP INDEX "IDX_1ef58c35ae5562242966d483a1"`); + await queryRunner.query(`DROP INDEX "IDX_7055bf87cc824d4d1268704818"`); + await queryRunner.query(`DROP TABLE "iot_device_payload_decoder_data_target_connection"`); + await queryRunner.query(`DROP INDEX "IDX_18ce5007f4efcbe92338c10f5d"`); + await queryRunner.query(`DROP TABLE "iot_device"`); + await queryRunner.query(`DROP TYPE "iot_device_type_enum"`); + await queryRunner.query(`DROP TABLE "device_model"`); + await queryRunner.query(`DROP TABLE "organization"`); + await queryRunner.query(`DROP TABLE "sigfox_group"`); + await queryRunner.query(`DROP TABLE "payload_decoder"`); + await queryRunner.query(`DROP INDEX "IDX_71bf2818fb2ad92e208d7aeadf"`); + await queryRunner.query(`DROP TABLE "permission"`); + await queryRunner.query(`DROP TYPE "permission_type_enum"`); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`DROP INDEX "IDX_4e710f4b84fd89dbbbab4616bf"`); + await queryRunner.query(`DROP TABLE "received_message_metadata"`); + await queryRunner.query(`DROP TABLE "received_message"`); + await queryRunner.query(`DROP TABLE "open_data_dk_dataset"`); + } + +} diff --git a/src/modules/api-key-management/api-key.module.ts b/src/modules/api-key-management/api-key.module.ts new file mode 100644 index 00000000..1272ec1d --- /dev/null +++ b/src/modules/api-key-management/api-key.module.ts @@ -0,0 +1,18 @@ +import { ApiKeyController } from "@admin-controller/api-key-controller/api-key.controller"; +import { SharedModule } from "@modules/shared.module"; +import { OrganizationModule } from "@modules/user-management/organization.module"; +import { PermissionModule } from "@modules/user-management/permission.module"; +import { forwardRef, Module } from "@nestjs/common"; +import { ApiKeyService } from "@services/api-key-management/api-key.service"; + +@Module({ + imports: [ + SharedModule, + forwardRef(() => PermissionModule), + forwardRef(() => OrganizationModule), + ], + controllers: [ApiKeyController], + providers: [ApiKeyService], + exports: [ApiKeyService], +}) +export class ApiKeyModule {} diff --git a/src/modules/app.module.ts b/src/modules/app.module.ts index 7d642ae9..08b48302 100644 --- a/src/modules/app.module.ts +++ b/src/modules/app.module.ts @@ -1,8 +1,3 @@ -import { HttpModule, Module, RequestMethod } from "@nestjs/common"; -import { ConfigModule, ConfigService } from "@nestjs/config"; -import { TypeOrmModule } from "@nestjs/typeorm"; -import { LoggerModule } from "nestjs-pino"; - import configuration from "@config/configuration"; import { PayloadDecoderKafkaModule } from "@modules/data-management/payload-decoder-kafka.module"; import { DataTargetKafkaModule } from "@modules/data-target/data-target-kafka.module"; @@ -11,6 +6,11 @@ import { DefaultModule } from "@modules/default.module"; import { ChirpstackAdministrationModule } from "@modules/device-integrations/chirpstack-administration.module"; import { ChirpstackMqttListenerModule } from "@modules/device-integrations/chirpstack-mqtt-listener.module"; import { ReceiveDataModule } from "@modules/device-integrations/receive-data.module"; +import { SigFoxAdministrationModule } from "@modules/device-integrations/sigfox-administration.module"; +import { SigfoxContractModule } from "@modules/device-integrations/sigfox-contract.module"; +import { SigfoxDeviceTypeModule } from "@modules/device-integrations/sigfox-device-type.module"; +import { SigfoxDeviceModule } from "@modules/device-integrations/sigfox-device.module"; +import { SigFoxGroupModule } from "@modules/device-integrations/sigfox-group.module"; import { SigFoxListenerModule } from "@modules/device-integrations/sigfox-listener.module"; import { ApplicationModule } from "@modules/device-management/application.module"; import { DataTargetModule } from "@modules/device-management/data-target.module"; @@ -21,16 +21,16 @@ import { KafkaModule } from "@modules/kafka.module"; import { AuthModule } from "@modules/user-management/auth.module"; import { OrganizationModule } from "@modules/user-management/organization.module"; import { PermissionModule } from "@modules/user-management/permission.module"; -import { SigFoxAdministrationModule } from "@modules/device-integrations/sigfox-administration.module"; -import { SigfoxDeviceTypeModule } from "@modules/device-integrations/sigfox-device-type.module"; -import { SigFoxGroupModule } from "@modules/device-integrations/sigfox-group.module"; -import { SigfoxContractModule } from "@modules/device-integrations/sigfox-contract.module"; -import { SigfoxDeviceModule } from "@modules/device-integrations/sigfox-device.module"; +import { HttpModule, Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; import { ScheduleModule } from "@nestjs/schedule"; -import { SearchModule } from "./search.module"; +import { TypeOrmModule } from "@nestjs/typeorm"; import { DeviceModelModule } from "./device-management/device-model.module"; -import { TestPayloadDecoderModule } from "./test-payload-decoder.module"; +import { MulticastModule } from "./device-management/multicast.module"; import { OpenDataDkSharingModule } from "./open-data-dk-sharing.module"; +import { SearchModule } from "./search.module"; +import { TestPayloadDecoderModule } from "./test-payload-decoder.module"; +import { IoTLoRaWANDeviceModule } from "./device-management/iot-lorawan-device.module"; @Module({ imports: [ @@ -47,12 +47,13 @@ import { OpenDataDkSharingModule } from "./open-data-dk-sharing.module"; username: configService.get("database.username"), password: configService.get("database.password"), database: "os2iot", - synchronize: true, + // Don't sync database 1-1 with code. Make migrations necessary + synchronize: false, logging: false, autoLoadEntities: true, retryAttempts: 0, maxQueryExecutionTime: 1000, // Log queries slower than 1000 ms - ssl: configService.get("database.ssl") + ssl: configService.get("database.ssl"), }), }), // LoggerModule.forRoot({ @@ -87,6 +88,8 @@ import { OpenDataDkSharingModule } from "./open-data-dk-sharing.module"; SearchModule, TestPayloadDecoderModule, OpenDataDkSharingModule, + MulticastModule, + IoTLoRaWANDeviceModule, ], controllers: [], providers: [], diff --git a/src/modules/data-target/data-target-fiware-sender.module.ts b/src/modules/data-target/data-target-fiware-sender.module.ts new file mode 100644 index 00000000..f7958de6 --- /dev/null +++ b/src/modules/data-target/data-target-fiware-sender.module.ts @@ -0,0 +1,11 @@ +import { HttpModule, Module } from "@nestjs/common"; + +import { FiwareDataTargetService } from "@services/data-targets/fiware-data-target.service"; + +@Module({ + imports: [HttpModule], + providers: [FiwareDataTargetService], + exports: [FiwareDataTargetService], +}) + +export class DataTargetFiwareSenderModule {} diff --git a/src/modules/data-target/data-target-kafka.module.ts b/src/modules/data-target/data-target-kafka.module.ts index 0af7e96b..6c841953 100644 --- a/src/modules/data-target/data-target-kafka.module.ts +++ b/src/modules/data-target/data-target-kafka.module.ts @@ -10,6 +10,7 @@ import { IoTDeviceModule } from "@modules/device-management/iot-device.module"; import { KafkaModule } from "@modules/kafka.module"; import { SharedModule } from "@modules/shared.module"; import { DataTargetKafkaListenerService } from "@services/data-targets/data-target-kafka-listener.service"; +import { DataTargetFiwareSenderModule } from "./data-target-fiware-sender.module"; @Module({ imports: [ @@ -17,6 +18,7 @@ import { DataTargetKafkaListenerService } from "@services/data-targets/data-targ HttpModule, KafkaModule, DataTargetSenderModule, + DataTargetFiwareSenderModule, DeviceIntegrationPersistenceModule, IoTDeviceModule, ChirpstackAdministrationModule, diff --git a/src/modules/device-integrations/chirpstack-administration.module.ts b/src/modules/device-integrations/chirpstack-administration.module.ts index 4d88cb69..ee9e5975 100644 --- a/src/modules/device-integrations/chirpstack-administration.module.ts +++ b/src/modules/device-integrations/chirpstack-administration.module.ts @@ -25,6 +25,9 @@ import { ServiceProfileService } from "@services/chirpstack/service-profile.serv DeviceProfileService, ChirpstackDeviceService, ], - exports: [ChirpstackDeviceService, ChirpstackGatewayService], + exports: [ + ChirpstackDeviceService, + ChirpstackGatewayService, + ], }) export class ChirpstackAdministrationModule {} diff --git a/src/modules/device-management/application.module.ts b/src/modules/device-management/application.module.ts index 4e0b46cc..2e46329f 100644 --- a/src/modules/device-management/application.module.ts +++ b/src/modules/device-management/application.module.ts @@ -6,12 +6,14 @@ import { OrganizationModule } from "@modules/user-management/organization.module import { ApplicationService } from "@services/device-management/application.service"; import { ChirpstackAdministrationModule } from "@modules/device-integrations/chirpstack-administration.module"; import { PermissionModule } from "@modules/user-management/permission.module"; +import { MulticastModule } from "./multicast.module"; @Module({ imports: [ SharedModule, forwardRef(() => OrganizationModule), forwardRef(() => PermissionModule), + forwardRef(() => MulticastModule), // because of circular reference ChirpstackAdministrationModule, ], exports: [ApplicationService], diff --git a/src/modules/device-management/iot-device.module.ts b/src/modules/device-management/iot-device.module.ts index 41e94058..10e46adc 100644 --- a/src/modules/device-management/iot-device.module.ts +++ b/src/modules/device-management/iot-device.module.ts @@ -12,6 +12,7 @@ import { PeriodicSigFoxCleanupService } from "@services/sigfox/periodic-sigfox-c import { IoTDeviceDownlinkService } from "@services/device-management/iot-device-downlink.service"; import { DeviceModelModule } from "./device-model.module"; import { IoTDevicePayloadDecoderController } from "@admin-controller/iot-device-payload-decoder.controller"; +import { IoTLoRaWANDeviceModule } from "./iot-lorawan-device.module"; @Module({ imports: [ @@ -22,6 +23,7 @@ import { IoTDevicePayloadDecoderController } from "@admin-controller/iot-device- SigfoxDeviceTypeModule, DeviceModelModule, forwardRef(() => SigfoxDeviceModule), + forwardRef(() => IoTLoRaWANDeviceModule), ], exports: [IoTDeviceService], controllers: [IoTDeviceController, IoTDevicePayloadDecoderController], diff --git a/src/modules/device-management/iot-lorawan-device.module.ts b/src/modules/device-management/iot-lorawan-device.module.ts new file mode 100644 index 00000000..ff26acae --- /dev/null +++ b/src/modules/device-management/iot-lorawan-device.module.ts @@ -0,0 +1,11 @@ +import { SharedModule } from "@modules/shared.module"; +import { Module } from "@nestjs/common"; +import { IoTLoRaWANDeviceService } from "@services/device-management/iot-lorawan-device.service"; + +@Module({ + imports: [SharedModule], + exports: [IoTLoRaWANDeviceService], + controllers: [], + providers: [IoTLoRaWANDeviceService], +}) +export class IoTLoRaWANDeviceModule {} diff --git a/src/modules/device-management/multicast.module.ts b/src/modules/device-management/multicast.module.ts new file mode 100644 index 00000000..8e05b4ab --- /dev/null +++ b/src/modules/device-management/multicast.module.ts @@ -0,0 +1,21 @@ +import { ChirpstackAdministrationModule } from "@modules/device-integrations/chirpstack-administration.module"; +import { SharedModule } from "@modules/shared.module"; +import { forwardRef, HttpModule, Module } from "@nestjs/common"; +import { MulticastController } from "../../controllers/admin-controller/multicast.controller"; +import { MulticastService } from "../../services/device-management/multicast.service"; +import { ApplicationModule } from "./application.module"; +import { IoTDeviceModule } from "./iot-device.module"; + +@Module({ + imports: [ + SharedModule, + forwardRef(() => ApplicationModule), // because of circular reference + HttpModule, + ChirpstackAdministrationModule, + IoTDeviceModule + ], + exports: [MulticastService], + controllers: [MulticastController], + providers: [MulticastService], +}) +export class MulticastModule {} diff --git a/src/modules/shared.module.ts b/src/modules/shared.module.ts index 3fc21d53..fdf8b8c0 100644 --- a/src/modules/shared.module.ts +++ b/src/modules/shared.module.ts @@ -6,6 +6,7 @@ import { DataTarget } from "@entities/data-target.entity"; import { GenericHTTPDevice } from "@entities/generic-http-device.entity"; import { GlobalAdminPermission } from "@entities/global-admin-permission.entity"; import { HttpPushDataTarget } from "@entities/http-push-data-target.entity"; +import { FiwareDataTarget } from "@entities/fiware-data-target.entity"; import { IoTDevicePayloadDecoderDataTargetConnection } from "@entities/iot-device-payload-decoder-data-target-connection.entity"; import { IoTDevice } from "@entities/iot-device.entity"; import { LoRaWANDevice } from "@entities/lorawan-device.entity"; @@ -25,15 +26,21 @@ import { WritePermission } from "@entities/write-permission.entity"; import { DeviceModel } from "@entities/device-model.entity"; import { OpenDataDkDataset } from "@entities/open-data-dk-dataset.entity"; import { AuditLog } from "@services/audit-log.service"; +import { ApiKey } from "@entities/api-key.entity"; +import { ApiKeyPermission } from "@entities/api-key-permission.entity"; +import { Multicast } from "@entities/multicast.entity"; +import { LorawanMulticastDefinition } from "@entities/lorawan-multicast.entity"; @Module({ imports: [ TypeOrmModule.forFeature([ + ApiKey, Application, DataTarget, GenericHTTPDevice, GlobalAdminPermission, HttpPushDataTarget, + FiwareDataTarget, IoTDevice, IoTDevicePayloadDecoderDataTargetConnection, DeviceModel, @@ -52,6 +59,9 @@ import { AuditLog } from "@services/audit-log.service"; SigFoxGroup, User, WritePermission, + ApiKeyPermission, + Multicast, + LorawanMulticastDefinition ]), ], providers: [AuditLog], diff --git a/src/modules/user-management/auth.module.ts b/src/modules/user-management/auth.module.ts index 8d429287..d5fb9c9e 100644 --- a/src/modules/user-management/auth.module.ts +++ b/src/modules/user-management/auth.module.ts @@ -13,6 +13,8 @@ import { AuthService } from "@services/user-management/auth.service"; import { AuthController } from "@user-management-controller/auth.controller"; import { KombitStrategy } from "@auth/kombit.strategy"; import { HandleRedirectUrlParameterMiddleware } from "@auth/handle-redirect-url-parameter.middleware"; +import { ApiKeyStrategy } from "@auth/api-key.strategy"; +import { ApiKeyModule } from "@modules/api-key-management/api-key.module"; @Module({ imports: [ @@ -31,8 +33,9 @@ import { HandleRedirectUrlParameterMiddleware } from "@auth/handle-redirect-url- forwardRef(() => UserModule), forwardRef(() => PermissionModule), forwardRef(() => OrganizationModule), + forwardRef(() => ApiKeyModule) ], - providers: [AuthService, LocalStrategy, JwtStrategy, KombitStrategy], + providers: [AuthService, LocalStrategy, JwtStrategy, KombitStrategy, ApiKeyStrategy], exports: [AuthService], controllers: [AuthController], }) diff --git a/src/services/api-key-management/api-key.service.ts b/src/services/api-key-management/api-key.service.ts new file mode 100644 index 00000000..37208734 --- /dev/null +++ b/src/services/api-key-management/api-key.service.ts @@ -0,0 +1,143 @@ +import { User } from "@entities/user.entity"; +import { ApiKeyResponseDto } from "@dto/api-key/api-key-response.dto"; +import { CreateApiKeyDto } from "@dto/api-key/create-api-key.dto"; +import { ListAllApiKeysResponseDto } from "@dto/api-key/list-all-api-keys-response.dto"; +import { ListAllApiKeysDto } from "@dto/api-key/list-all-api-keys.dto"; +import { DeleteResponseDto } from "@dto/delete-application-response.dto"; +import { ApiKeyPermission } from "@entities/api-key-permission.entity"; +import { ApiKey } from "@entities/api-key.entity"; +import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { PermissionService } from "@services/user-management/permission.service"; +import { Repository } from "typeorm"; +import { v4 as uuidv4 } from "uuid"; +import { UpdateApiKeyDto } from "@dto/api-key/update-api-key.dto"; +import { nameof } from "@helpers/type-helper"; + +@Injectable() +export class ApiKeyService { + constructor( + @InjectRepository(ApiKey) + private apiKeyRepository: Repository, + @Inject(forwardRef(() => PermissionService)) + private permissionService: PermissionService + ) {} + private readonly logger = new Logger(ApiKeyService.name, true); + + findOne(key: string): Promise { + return this.apiKeyRepository.findOne({ + where: { key }, + relations: ["systemUser"], + }); + } + + findOneByIdWithPermissions(id: number): Promise { + return this.apiKeyRepository.findOne({ + where: { id }, + relations: [nameof("permissions")], + }); + } + + findOneByIdWithRelations(id: number): Promise { + return this.apiKeyRepository.findOne({ + where: { id }, + relations: [nameof("permissions"), nameof("systemUser")], + }); + } + + async findAllByOrganizationId( + query: ListAllApiKeysDto + ): Promise { + const permIds = ( + await this.permissionService.getAllPermissionsInOrganizations([ + query.organizationId, + ]) + ).data.map(x => x.id); + + let dbQuery = this.apiKeyRepository + .createQueryBuilder("api_key") + .innerJoinAndSelect("api_key.permissions", "perm") + .innerJoinAndSelect("perm.organization", "org") + .take(query.limit ? +query.limit : 100) + .skip(query.offset ? +query.offset : 0); + + if (permIds.length) { + dbQuery = dbQuery.where("perm.id IN (:...permIds)", { permIds }); + } + + if (query.orderOn && query.sort) { + dbQuery = dbQuery.orderBy( + `api_key.${query.orderOn}`, + query.sort.toUpperCase() as "ASC" | "DESC" + ); + } + + const [data, count] = await dbQuery.getManyAndCount(); + + return { + data, + count, + }; + } + + async create(dto: CreateApiKeyDto, userId: number): Promise { + // Create the key + const apiKey = new ApiKey(); + apiKey.key = uuidv4(); + apiKey.name = dto.name; + apiKey.updatedBy = userId; + apiKey.createdBy = userId; + + // Create the system user + const systemUser = new User(); + systemUser.active = false; + systemUser.isSystemUser = true; + systemUser.passwordHash = uuidv4(); // Random password, user can never log in + systemUser.name = apiKey.name; + apiKey.systemUser = systemUser; + + if (dto.permissionIds?.length > 0) { + const permissionsDb = await this.permissionService.findManyByIds( + dto.permissionIds + ); + + apiKey.permissions = permissionsDb.map( + pm => ({ ...pm, apiKeys: null } as ApiKeyPermission) + ); + } + + return await this.apiKeyRepository.save(apiKey); + } + + async update( + id: number, + dto: UpdateApiKeyDto, + userId: number + ): Promise { + const apiKey = await this.findOneByIdWithRelations(id); + apiKey.name = dto.name; + apiKey.updatedBy = userId; + + if (dto.permissionIds?.length) { + const permissionsDb = await this.permissionService.findManyByIds( + dto.permissionIds + ); + apiKey.permissions = permissionsDb.map(pm => ({ + ...pm, + apiKeys: [], + })); + } + + if (dto.name !== apiKey.name) { + apiKey.systemUser.name = dto.name; + apiKey.name = dto.name; + } + + return await this.apiKeyRepository.save(apiKey); + } + + async delete(id: number): Promise { + const res = await this.apiKeyRepository.delete(id); + return new DeleteResponseDto(res.affected); + } +} diff --git a/src/services/chirpstack/chirpstack-device.service.ts b/src/services/chirpstack/chirpstack-device.service.ts index ce7655af..b86918a5 100644 --- a/src/services/chirpstack/chirpstack-device.service.ts +++ b/src/services/chirpstack/chirpstack-device.service.ts @@ -26,6 +26,9 @@ import { ChirpstackManyDeviceResponseDto } from "@dto/chirpstack/chirpstack-many import { IoTDevice } from "@entities/iot-device.entity"; import { LoRaWANDeviceWithChirpstackDataDto } from "@dto/lorawan-device-with-chirpstack-data.dto"; import { ActivationType } from "@enum/lorawan-activation-type.enum"; +import { ChirpstackDeviceId } from "@dto/chirpstack/chirpstack-device-id.dto"; +import { ChirpstackApplicationResponseDto } from "@dto/chirpstack/chirpstack-application-response.dto"; +import { groupBy } from "lodash"; @Injectable() export class ChirpstackDeviceService extends GenericChirpstackConfigurationService { @@ -41,24 +44,23 @@ export class ChirpstackDeviceService extends GenericChirpstackConfigurationServi DEFAULT_DESCRIPTION = "Created by OS2IoT"; async findOrCreateDefaultApplication( - dto: CreateChirpstackDeviceDto + dto: CreateChirpstackDeviceDto, + applications: ListAllChirpstackApplicationsResponseDto = null ): Promise { const organizationID = await this.getDefaultOrganizationId(); // Fetch applications - const applications = await this.getAllWithPagination< - ListAllChirpstackApplicationsResponseDto - >(`applications?limit=100&organizationID=${organizationID}`); + applications = + applications ?? + (await this.getAllWithPagination( + `applications?limit=100&organizationID=${organizationID}` + )); // if default exist use it - let applicationId; - applications.result.forEach(element => { - if ( + let applicationId = applications.result.find( + element => element.serviceProfileID.toLowerCase() === dto.device.serviceProfileID.toLowerCase() && element.name.startsWith(this.defaultApplicationName) - ) { - applicationId = element.id; - } - }); + )?.id; // otherwise create default if (!applicationId) { applicationId = await this.createDefaultApplication( @@ -72,7 +74,7 @@ export class ChirpstackDeviceService extends GenericChirpstackConfigurationServi } private async createDefaultApplication( - applicationId: any, + applicationId: string, dto: CreateChirpstackDeviceDto, organizationID: string ) { @@ -89,10 +91,10 @@ export class ChirpstackDeviceService extends GenericChirpstackConfigurationServi return applicationId; } - async makeCreateChirpstackDeviceDto( + makeCreateChirpstackDeviceDto( dto: CreateLoRaWANSettingsDto, name: string - ): Promise { + ): CreateChirpstackDeviceDto { const csDto = new ChirpstackDeviceContentsDto(); csDto.name = `${this.DEVICE_NAME_PREFIX}${name}`.toLowerCase(); csDto.description = this.DEFAULT_DESCRIPTION; @@ -242,15 +244,18 @@ export class ChirpstackDeviceService extends GenericChirpstackConfigurationServi return res.status == 200; } - async createOrUpdateDevice(dto: CreateChirpstackDeviceDto): Promise { - let res; - if (await this.isDeviceAlreadyCreated(dto.device.devEUI)) { + async createOrUpdateDevice( + dto: CreateChirpstackDeviceDto, + lorawanDevices: ChirpstackDeviceId[] = null + ): Promise { + let res: AxiosResponse; + if (await this.isDeviceAlreadyCreated(dto.device.devEUI, lorawanDevices)) { res = await this.put(`devices`, dto, dto.device.devEUI); } else { res = await this.post(`devices`, dto); } - if (res.status != 200) { + if (res.status !== 200) { this.logger.warn( `Could not create Chirpstack Device using body: ${JSON.stringify(dto)}` ); @@ -258,7 +263,7 @@ export class ChirpstackDeviceService extends GenericChirpstackConfigurationServi return false; } - return res.status == 200; + return true; } async getChirpstackApplication( @@ -301,8 +306,15 @@ export class ChirpstackDeviceService extends GenericChirpstackConfigurationServi } } + /** + * Fetch and set LoRaWAN settings on the given device. This is not immutable. + * @param iotDevice + * @param applications + * @returns The mutated device + */ async enrichLoRaWANDevice( - iotDevice: IoTDevice + iotDevice: IoTDevice, + applications: ChirpstackApplicationResponseDto[] = [] ): Promise { const loraDevice = iotDevice as LoRaWANDeviceWithChirpstackDataDto; loraDevice.lorawanSettings = new CreateLoRaWANSettingsDto(); @@ -316,9 +328,19 @@ export class ChirpstackDeviceService extends GenericChirpstackConfigurationServi loraDevice.lorawanSettings.deviceStatusBattery = csData.deviceStatusBattery; loraDevice.lorawanSettings.deviceStatusMargin = csData.deviceStatusMargin; - const csAppliation = await this.getChirpstackApplication(csData.applicationID); - loraDevice.lorawanSettings.serviceProfileID = - csAppliation.application.serviceProfileID; + const appMatch = applications.find(app => app.id === csData.applicationID); + loraDevice.lorawanSettings.serviceProfileID = appMatch + ? appMatch.serviceProfileID + : loraDevice.lorawanSettings.serviceProfileID; + + if (!loraDevice.lorawanSettings.serviceProfileID) { + const csAppliation = await this.getChirpstackApplication( + csData.applicationID + ); + loraDevice.lorawanSettings.serviceProfileID = + csAppliation.application.serviceProfileID; + } + return loraDevice; } @@ -344,16 +366,48 @@ export class ChirpstackDeviceService extends GenericChirpstackConfigurationServi } } - async isDeviceAlreadyCreated(deviceEUI: string): Promise { - const devices = await this.getAllChirpstackDevices(); - const alreadyExists = devices.some(x => { - return x.devEUI.toLowerCase() == deviceEUI.toLowerCase(); - }); + async isDeviceAlreadyCreated( + deviceEUI: string, + chirpstackIds: ChirpstackDeviceId[] = null + ): Promise { + const devices = !chirpstackIds + ? await this.getAllChirpstackDevices() + : chirpstackIds; + const alreadyExists = devices.some( + x => x.devEUI.toLowerCase() === deviceEUI.toLowerCase() + ); return alreadyExists; } - private async getAllChirpstackDevices(): Promise { - return (await this.get("devices?limit=1000")).result; + /** + * Fetch LoRaWAN applications by the device application id. This **assumes** that + * the device chirpstack application id always reflects what's on Chirpstack. + * @param devices + * @returns + */ + public async getLoRaWANApplications( + devices: LoRaWANDeviceWithChirpstackDataDto[] + ): Promise { + const loraDevicesByAppId = groupBy( + devices, + device => device.chirpstackApplicationId + ); + + const res: ChirpstackSingleApplicationResponseDto[] = []; + + // Avoid async .forEach and .map when querying the API. They execute whatever's inside in "parallel" which can result in timeouts. + for (const appId of Object.keys(loraDevicesByAppId)) { + res.push(await this.getChirpstackApplication(appId)); + } + + return res; + } + + private async getAllChirpstackDevices( + limit = 1000 + ): Promise { + return (await this.get(`devices?limit=${limit}`)) + .result; } private async createApplication( diff --git a/src/services/chirpstack/generic-chirpstack-configuration.service.ts b/src/services/chirpstack/generic-chirpstack-configuration.service.ts index 18038593..ebf5e785 100644 --- a/src/services/chirpstack/generic-chirpstack-configuration.service.ts +++ b/src/services/chirpstack/generic-chirpstack-configuration.service.ts @@ -15,6 +15,7 @@ import { AuthorizationType } from "@enum/authorization-type.enum"; import { ErrorCodes } from "@enum/error-codes.enum"; import { JwtToken } from "./jwt-token"; +import { ListAllChirpstackApplicationsResponseDto } from "@dto/chirpstack/list-all-applications-response.dto"; @Injectable() export class GenericChirpstackConfigurationService { @@ -30,19 +31,19 @@ export class GenericChirpstackConfigurationService { private readonly innerLogger = new Logger(GenericChirpstackConfigurationService.name); setupHeader(endPoint: string, limit?: number, offset?: number): HeaderDto { - // Default timeout value in ms - const timeout = 30000; + const timeoutMs = 30 * 1000; let url = this.baseUrl + "/api/" + endPoint; // If limits are supplied, add these as query params - if (limit != null && offset != null) { - url += `${endPoint.indexOf("?") >= 0 ? "&" : "?" - }limit=${limit}&offset=${offset}`; + if (limit != null && offset != null) { + url += `${ + endPoint.indexOf("?") >= 0 ? "&" : "?" + }limit=${limit}&offset=${offset}`; } - - let headerDto: HeaderDto = { + + const headerDto: HeaderDto = { url, - timeout, + timeout: timeoutMs, authorizationType: AuthorizationType.HEADER_BASED_AUTHORIZATION, authorizationHeader: "Bearer " + JwtToken.setupToken(), }; @@ -194,6 +195,14 @@ export class GenericChirpstackConfigurationService { } } + async getAllApplicationsWithPagination( + organizationID: string + ): Promise { + return this.getAllWithPagination( + `applications?limit=100&organizationID=${organizationID}` + ); + } + async getAllWithPagination( endpoint: string, limit?: number, diff --git a/src/services/data-targets/data-target-kafka-listener.service.ts b/src/services/data-targets/data-target-kafka-listener.service.ts index 72f45c61..5d3be2e2 100644 --- a/src/services/data-targets/data-target-kafka-listener.service.ts +++ b/src/services/data-targets/data-target-kafka-listener.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger, NotImplementedException } from "@nestjs/common"; import { TransformedPayloadDto } from "@dto/kafka/transformed-payload.dto"; import { DataTarget } from "@entities/data-target.entity"; -import { HttpPushDataTarget } from "@entities/http-push-data-target.entity"; import { IoTDevice } from "@entities/iot-device.entity"; import { DataTargetType } from "@enum/data-target-type.enum"; import { KafkaTopic } from "@enum/kafka-topic.enum"; @@ -13,6 +12,7 @@ import { IoTDeviceService } from "@services/device-management/iot-device.service import { AbstractKafkaConsumer } from "@services/kafka/kafka.abstract.consumer"; import { CombinedSubscribeTo } from "@services/kafka/kafka.decorator"; import { KafkaPayload } from "@services/kafka/kafka.message"; +import { FiwareDataTargetService } from "./fiware-data-target.service"; const UNIQUE_NAME_FOR_KAFKA = "DataTargetKafka"; @@ -22,6 +22,7 @@ export class DataTargetKafkaListenerService extends AbstractKafkaConsumer { private ioTDeviceService: IoTDeviceService, private dataTargetService: DataTargetService, private httpPushDataTargetService: HttpPushDataTargetService, + private fiwareDataTargetService: FiwareDataTargetService, private ioTDevicePayloadDecoderDataTargetConnectionService: IoTDevicePayloadDecoderDataTargetConnectionService ) { super(); @@ -75,29 +76,26 @@ export class DataTargetKafkaListenerService extends AbstractKafkaConsumer { dataTargets.forEach(async target => { if (target.type == DataTargetType.HttpPush) { try { - await this.sendToHttpPushDataTarget(target, dto); + const status = await this.httpPushDataTargetService.send(target, dto); + this.logger.debug(`Sent to HttpPush target: ${JSON.stringify(status)}`); } catch (err) { this.logger.error( `Error while sending to Http Push DataTarget: ${err}` ); } - } else { + } else if (target.type == DataTargetType.Fiware) { + try { + const status = await this.fiwareDataTargetService.send(target, dto); + this.logger.debug(`Sent to FIWARE target: ${JSON.stringify(status)}`); + } catch (err) { + this.logger.error( + `Error while sending to FIWARE DataTarget: ${err}` + ); + } + } else + { throw new NotImplementedException(`Not implemented for: ${target.type}`); } }); } - - private async sendToHttpPushDataTarget( - target: DataTarget, - dto: TransformedPayloadDto - ) { - const config = (target as HttpPushDataTarget).toConfiguration(); - const data = { - rawBody: JSON.stringify(dto.payload), - mimeType: "application/json", - }; - - const status = await this.httpPushDataTargetService.send(config, data); - this.logger.debug(`Sent to HttpPush target: ${JSON.stringify(status)}`); - } } diff --git a/src/services/data-targets/data-target.service.ts b/src/services/data-targets/data-target.service.ts index 7c488179..fadffecb 100644 --- a/src/services/data-targets/data-target.service.ts +++ b/src/services/data-targets/data-target.service.ts @@ -20,6 +20,7 @@ import { ErrorCodes } from "@enum/error-codes.enum"; import { ApplicationService } from "@services/device-management/application.service"; import { OpenDataDkDataset } from "@entities/open-data-dk-dataset.entity"; import { CreateOpenDataDkDatasetDto } from "@dto/create-open-data-dk-dataset.dto"; +import { FiwareDataTarget } from "@entities/fiware-data-target.entity"; @Injectable() export class DataTargetService { @@ -121,8 +122,8 @@ export class DataTargetService { userId: number ): Promise { const childType = dataTargetTypeMap[createDataTargetDto.type]; - const dataTarget = this.createDataTargetByDto(childType); - + + const dataTarget = this.createDataTargetByDto(childType); const mappedDataTarget = await this.mapDtoToDataTarget( createDataTargetDto, dataTarget @@ -204,7 +205,7 @@ export class DataTargetService { throw new BadRequestException(ErrorCodes.IdMissing); } - this.setAuthorizationHeader(dataTargetDto, dataTarget); + this.mapDtoToTypeSpecificDataTarget(dataTargetDto, dataTarget); return dataTarget; } @@ -224,21 +225,26 @@ export class DataTargetService { return o; } - private setAuthorizationHeader( + private mapDtoToTypeSpecificDataTarget( dataTargetDto: CreateDataTargetDto, dataTarget: DataTarget ) { if (dataTargetDto.type === DataTargetType.HttpPush) { - (dataTarget as HttpPushDataTarget).url = dataTargetDto.url; - (dataTarget as HttpPushDataTarget).timeout = dataTargetDto.timeout; - (dataTarget as HttpPushDataTarget).authorizationHeader = - dataTargetDto.authorizationHeader; + const httpPushDataTarget = (dataTarget as HttpPushDataTarget); + httpPushDataTarget.url = dataTargetDto.url; + httpPushDataTarget.timeout = dataTargetDto.timeout; + httpPushDataTarget.authorizationHeader = dataTargetDto.authorizationHeader; + } else if (dataTargetDto.type === DataTargetType.Fiware) { + const fiwareDataTarget = (dataTarget as FiwareDataTarget); + fiwareDataTarget.url = dataTargetDto.url; + fiwareDataTarget.timeout = dataTargetDto.timeout; + fiwareDataTarget.authorizationHeader = dataTargetDto.authorizationHeader; + fiwareDataTarget.tenant = dataTargetDto.tenant; + fiwareDataTarget.context = dataTargetDto.context; } } - private createDataTargetByDto(childDataTargetType: { - new (): T; - }): T { + private createDataTargetByDto(childDataTargetType: any ):T { return new childDataTargetType(); } } diff --git a/src/services/data-targets/fiware-data-target.service.ts b/src/services/data-targets/fiware-data-target.service.ts new file mode 100644 index 00000000..a72e0546 --- /dev/null +++ b/src/services/data-targets/fiware-data-target.service.ts @@ -0,0 +1,107 @@ +import { HttpService, Injectable, Logger } from "@nestjs/common"; +import { AxiosRequestConfig } from "axios"; + +import { AuthorizationType } from "@enum/authorization-type.enum"; +import { DataTarget } from "@entities/data-target.entity"; +import { FiwareDataTarget } from "@entities/fiware-data-target.entity"; +import { TransformedPayloadDto } from "@dto/kafka/transformed-payload.dto"; +import { DataTargetSendStatus } from "@interfaces/data-target-send-status.interface"; +import { FiwareDataTargetConfiguration } from "@interfaces/fiware-data-target-configuration.interface"; +import { BaseDataTargetService } from "@services/data-targets/base-data-target.service"; + +@Injectable() +export class FiwareDataTargetService extends BaseDataTargetService { + constructor(private httpService: HttpService) { + super(); + } + + protected readonly logger = new Logger(FiwareDataTargetService.name); + + // eslint-disable-next-line max-lines-per-function + async send( + datatarget: DataTarget, + dto: TransformedPayloadDto + ): Promise { + + const config: FiwareDataTargetConfiguration = (datatarget as FiwareDataTarget).toConfiguration(); + + + // Setup HTTP client + const axiosConfig = this.makeAxiosConfiguration(config); + + const rawBody: string = JSON.stringify(dto.payload); + + const endpointUrl = `${config.url}/ngsi-ld/v1/entityOperations/upsert/`; + const target = `FiwareDataTarget(${endpointUrl})`; + + try { + const result = await this.httpService + .post(endpointUrl, rawBody, axiosConfig) + .toPromise(); + + this.logger.debug( + `FiwareDataTarget result: '${JSON.stringify(result.data)}'` + ); + if (!result.status.toString().startsWith("2")) { + this.logger.warn( + `Got a non-2xx status-code: ${result.status.toString()} and message: ${ + result.statusText + }` + ); + } + return this.success(target); + } catch (err) { + this.logger.error(`FiwareDataTarget got error: ${err}`); + return this.failure(target, err); + } + } + + makeAxiosConfiguration( + config: FiwareDataTargetConfiguration + ): AxiosRequestConfig { + + const axiosConfig: AxiosRequestConfig = { + timeout: config.timeout, + headers: this.getHeaders(config), + }; + + if (config.authorizationType !== null && + config.authorizationType !== AuthorizationType.NO_AUTHORIZATION + ) { + if (config.authorizationType === AuthorizationType.HTTP_BASIC_AUTHORIZATION) { + axiosConfig.auth = { + username: config.username, + password: config.password, + }; + } else if ( + config.authorizationType === AuthorizationType.HEADER_BASED_AUTHORIZATION + ) { + axiosConfig.headers["Authorization"] = config.authorizationHeader; + } + } + return axiosConfig; + } + + getHeaders(config: FiwareDataTargetConfiguration) :any + { + let headers :any = {} + + if(config.context) { + headers = { + "Content-Type": "application/json", + Link: `<${config.context}>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"` + }; + } else { + headers = { + "Content-Type": "application/ld+json", + }; + } + + if (config.tenant) + { + headers["NGSILD-Tenant"] = config.tenant; + } + + return headers; + } +} diff --git a/src/services/data-targets/http-push-data-target.service.ts b/src/services/data-targets/http-push-data-target.service.ts index 135b5694..9cbfff24 100644 --- a/src/services/data-targets/http-push-data-target.service.ts +++ b/src/services/data-targets/http-push-data-target.service.ts @@ -1,7 +1,9 @@ import { HttpService, Injectable, Logger } from "@nestjs/common"; import { AxiosRequestConfig } from "axios"; - import { AuthorizationType } from "@enum/authorization-type.enum"; +import { DataTarget } from "@entities/data-target.entity"; +import { HttpPushDataTarget } from "@entities/http-push-data-target.entity"; +import { TransformedPayloadDto } from "@dto/kafka/transformed-payload.dto"; import { DataTargetSendStatus } from "@interfaces/data-target-send-status.interface"; import { HttpPushDataTargetConfiguration } from "@interfaces/http-push-data-target-configuration.interface"; import { HttpPushDataTargetData } from "@interfaces/http-push-data-target-data.interface"; @@ -17,9 +19,16 @@ export class HttpPushDataTargetService extends BaseDataTargetService { // eslint-disable-next-line max-lines-per-function async send( - config: HttpPushDataTargetConfiguration, - data: HttpPushDataTargetData + datatarget: DataTarget, + dto: TransformedPayloadDto ): Promise { + + const data: HttpPushDataTargetData = { + rawBody: JSON.stringify(dto.payload), + mimeType: "application/json" + }; + const config: HttpPushDataTargetConfiguration = (datatarget as HttpPushDataTarget).toConfiguration(); + // Setup HTTP client const axiosConfig = HttpPushDataTargetService.makeAxiosConfiguration( config, diff --git a/src/services/device-management/application.service.ts b/src/services/device-management/application.service.ts index 380a8eb8..a3c288ec 100644 --- a/src/services/device-management/application.service.ts +++ b/src/services/device-management/application.service.ts @@ -1,4 +1,4 @@ -import { LoRaWANDevice } from '@entities/lorawan-device.entity'; +import { LoRaWANDevice } from "@entities/lorawan-device.entity"; import { Inject, Injectable, forwardRef, ConflictException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { DeleteResult, getManager, In, QueryBuilder, Repository } from "typeorm"; @@ -17,6 +17,8 @@ import { PermissionService } from "@services/user-management/permission.service" import { ErrorCodes } from "@enum/error-codes.enum"; import { IoTDevice } from "@entities/iot-device.entity"; import { ListAllIoTDevicesResponseDto } from "@dto/list-all-iot-devices-response.dto"; +import { Multicast } from "@entities/multicast.entity"; +import { MulticastService } from "./multicast.service"; @Injectable() export class ApplicationService { @@ -25,6 +27,7 @@ export class ApplicationService { private applicationRepository: Repository, @Inject(forwardRef(() => OrganizationService)) private organizationService: OrganizationService, + private multicastService: MulticastService, private chirpstackDeviceService: ChirpstackDeviceService, @Inject(forwardRef(() => PermissionService)) private permissionService: PermissionService @@ -151,11 +154,18 @@ export class ApplicationService { }); } + async findManyWithOrganisation(ids: number[]): Promise { + return await this.applicationRepository.findByIds(ids, { + relations: ["belongsTo"], + }); + } + async findOne(id: number): Promise { const app = await this.applicationRepository.findOneOrFail(id, { relations: [ "iotDevices", "dataTargets", + "multicasts", "iotDevices.receivedMessagesMetadata", "belongsTo", ], @@ -208,6 +218,7 @@ export class ApplicationService { ); mappedApplication.iotDevices = []; mappedApplication.dataTargets = []; + mappedApplication.multicasts = []; mappedApplication.createdBy = userId; mappedApplication.updatedBy = userId; const app = await this.applicationRepository.save(mappedApplication); @@ -238,7 +249,7 @@ export class ApplicationService { async delete(id: number): Promise { const application = await this.applicationRepository.findOne({ where: { id: id }, - relations: ["iotDevices"], + relations: ["iotDevices", "multicasts"], }); // Don't allow delete if this application contains any sigfox devices. @@ -251,14 +262,25 @@ export class ApplicationService { } // Delete all LoRaWAN devices in ChirpStack - const loRaWANDevices = application.iotDevices - .filter(device => device.type === IoTDeviceType.LoRaWAN); + const loRaWANDevices = application.iotDevices.filter( + device => device.type === IoTDeviceType.LoRaWAN + ); - for (let device of loRaWANDevices) { + for (const device of loRaWANDevices) { const lwDevice = device as LoRaWANDevice; await this.chirpstackDeviceService.deleteDevice(lwDevice.deviceEUI); } + //delete all multicats + const multicasts = application.multicasts; + for (const multicast of multicasts) { + const dbMulticast = await this.multicastService.findOne(multicast.id); + + await this.multicastService.deleteMulticastChirpstack( + dbMulticast.lorawanMulticastDefinition.chirpstackGroupId + ); + } + return this.applicationRepository.delete(id); } @@ -310,18 +332,28 @@ export class ApplicationService { .orderBy(orderByColumn, direction) .getManyAndCount(); - // Need to get LoRa details to get battery status ... - await Promise.all( - data.map(async x => { - if (x.type == IoTDeviceType.LoRaWAN) { - x = await this.chirpstackDeviceService.enrichLoRaWANDevice(x); - } - }) + // Fetch LoRa details one by one to get battery status. The LoRa API doesn't support query by multiple deveui's to reduce the calls. + // Reduce calls by pre-fetching service profile ids by application id. The applications is usually the same + const loraDevices = data.filter( + device => device.type === IoTDeviceType.LoRaWAN + ) as LoRaWANDeviceWithChirpstackDataDto[]; + const applications = await this.chirpstackDeviceService.getLoRaWANApplications( + loraDevices ); + const loraApplications = applications.map( + app => app.application + ); + + for (const device of loraDevices) { + await this.chirpstackDeviceService.enrichLoRaWANDevice( + device, + loraApplications + ); + } return { - data: data, - count: count, + data, + count, }; } diff --git a/src/services/device-management/device-model.service.ts b/src/services/device-management/device-model.service.ts index de6d4365..43b61338 100644 --- a/src/services/device-management/device-model.service.ts +++ b/src/services/device-management/device-model.service.ts @@ -77,6 +77,15 @@ export class DeviceModelService { }); } + async getByIdsWithRelations(ids: number[]): Promise { + return this.repository.findByIds(ids, { + relations: ["belongsTo"], + loadRelationIds: { + relations: ["createdBy", "updatedBy"], + }, + }); + } + async create(dto: CreateDeviceModelDto, userId: number): Promise { const deviceModel = new DeviceModel(); deviceModel.belongsTo = await this.organizationService.findById(dto.belongsToId); diff --git a/src/services/device-management/iot-device.service.ts b/src/services/device-management/iot-device.service.ts index 44bb3ffe..036b7543 100644 --- a/src/services/device-management/iot-device.service.ts +++ b/src/services/device-management/iot-device.service.ts @@ -1,47 +1,60 @@ -import { - BadRequestException, - Injectable, - Logger, - NotFoundException, -} from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Point } from "geojson"; -import { DeleteResult, Repository, getManager, SelectQueryBuilder } from "typeorm"; - +import { DeviceDownlinkQueueResponseDto } from "@dto/chirpstack/chirpstack-device-downlink-queue-response.dto"; +import { ChirpstackDeviceId } from "@dto/chirpstack/chirpstack-device-id.dto"; +import { ListAllChirpstackApplicationsResponseDto } from "@dto/chirpstack/list-all-applications-response.dto"; import { CreateIoTDeviceDto } from "@dto/create-iot-device.dto"; +import { CreateSigFoxSettingsDto } from "@dto/create-sigfox-settings.dto"; +import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; +import { CreateIoTDeviceMapDto } from "@dto/iot-device/create-iot-device-map.dto"; +import { IotDeviceBatchResponseDto } from "@dto/iot-device/iot-device-batch-response.dto"; +import { UpdateIoTDeviceBatchDto } from "@dto/iot-device/update-iot-device-batch.dto"; +import { + IoTDeviceMinimal, + IoTDeviceMinimalRaw, + ListAllIoTDevicesMinimalResponseDto, +} from "@dto/list-all-iot-devices-minimal-response.dto"; import { LoRaWANDeviceWithChirpstackDataDto } from "@dto/lorawan-device-with-chirpstack-data.dto"; +import { SigFoxDeviceWithBackendDataDto } from "@dto/sigfox-device-with-backend-data.dto"; +import { CreateSigFoxApiDeviceRequestDto } from "@dto/sigfox/external/create-sigfox-api-device-request.dto"; +import { + SigFoxApiDeviceContent, + SigFoxApiDeviceResponse, +} from "@dto/sigfox/external/sigfox-api-device-response.dto"; +import { UpdateSigFoxApiDeviceRequestDto } from "@dto/sigfox/external/update-sigfox-api-device-request.dto"; import { UpdateIoTDeviceDto } from "@dto/update-iot-device.dto"; import { ErrorCodes } from "@entities/enum/error-codes.enum"; import { GenericHTTPDevice } from "@entities/generic-http-device.entity"; import { IoTDevice } from "@entities/iot-device.entity"; import { LoRaWANDevice } from "@entities/lorawan-device.entity"; import { SigFoxDevice } from "@entities/sigfox-device.entity"; +import { SigFoxGroup } from "@entities/sigfox-group.entity"; import { iotDeviceTypeMap } from "@enum/device-type-mapping"; import { IoTDeviceType } from "@enum/device-type.enum"; import { ActivationType } from "@enum/lorawan-activation-type.enum"; +import { + filterValidIotDeviceMaps, + isValidIoTDeviceMap, + mapAllDevicesByProcessed, +} from "@helpers/iot-device.helper"; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; import { ChirpstackDeviceService } from "@services/chirpstack/chirpstack-device.service"; import { ApplicationService } from "@services/device-management/application.service"; -import { CreateSigFoxApiDeviceRequestDto } from "@dto/sigfox/external/create-sigfox-api-device-request.dto"; +import { SigFoxApiDeviceTypeService } from "@services/sigfox/sigfox-api-device-type.service"; import { SigFoxApiDeviceService } from "@services/sigfox/sigfox-api-device.service"; import { SigFoxGroupService } from "@services/sigfox/sigfox-group.service"; -import { SigFoxDeviceWithBackendDataDto } from "@dto/sigfox-device-with-backend-data.dto"; -import { SigFoxGroup } from "@entities/sigfox-group.entity"; -import { UpdateSigFoxApiDeviceRequestDto } from "@dto/sigfox/external/update-sigfox-api-device-request.dto"; -import { - SigFoxApiDeviceContent, - SigFoxApiDeviceResponse, -} from "@dto/sigfox/external/sigfox-api-device-response.dto"; -import { SigFoxApiDeviceTypeService } from "@services/sigfox/sigfox-api-device-type.service"; -import { CreateSigFoxSettingsDto } from "@dto/create-sigfox-settings.dto"; -import { DeviceDownlinkQueueResponseDto } from "@dto/chirpstack/chirpstack-device-downlink-queue-response.dto"; -import { DeviceModel } from "@entities/device-model.entity"; +import { DeleteResult, getManager, ILike, Repository, SelectQueryBuilder } from "typeorm"; import { DeviceModelService } from "./device-model.service"; -import { - IoTDeviceMinimal, - IoTDeviceMinimalRaw, - ListAllIoTDevicesMinimalResponseDto, -} from "@dto/list-all-iot-devices-minimal-response.dto"; -import { AuthenticatedRequest } from "@dto/internal/authenticated-request"; +import { IoTLoRaWANDeviceService } from "./iot-lorawan-device.service"; + +type IoTDeviceOrSpecialized = + | IoTDevice + | LoRaWANDeviceWithChirpstackDataDto + | SigFoxDeviceWithBackendDataDto; @Injectable() export class IoTDeviceService { @@ -59,7 +72,8 @@ export class IoTDeviceService { private sigfoxApiDeviceService: SigFoxApiDeviceService, private sigfoxApiDeviceTypeService: SigFoxApiDeviceTypeService, private sigfoxGroupService: SigFoxGroupService, - private deviceModelService: DeviceModelService + private deviceModelService: DeviceModelService, + private ioTLoRaWANDeviceService: IoTLoRaWANDeviceService ) {} private readonly logger = new Logger(IoTDeviceService.name); @@ -85,9 +99,7 @@ export class IoTDeviceService { async findOneWithApplicationAndMetadata( id: number, enrich?: boolean - ): Promise< - IoTDevice | LoRaWANDeviceWithChirpstackDataDto | SigFoxDeviceWithBackendDataDto - > { + ): Promise { // Repository syntax doesn't yet support ordering by relation: https://github.com/typeorm/typeorm/issues/2620 // Therefore we use the QueryBuilder ... const iotDevice = await this.queryDatabaseForIoTDevice(id); @@ -108,6 +120,12 @@ export class IoTDeviceService { return iotDevice; } + async findManyWithApplicationAndMetadata( + ids: number[] + ): Promise { + return this.queryDatabaseForIoTDevices(ids); + } + async enrichSigFoxDevice( iotDevice: IoTDevice ): Promise { @@ -248,11 +266,10 @@ export class IoTDeviceService { return thisDevice; } - private async queryDatabaseForIoTDevice(id: number) { - return await this.iotDeviceRepository + private buildIoTDeviceWithRelationsQuery(): SelectQueryBuilder { + return this.iotDeviceRepository .createQueryBuilder("iot_device") .loadAllRelationIds({ relations: ["createdBy", "updatedBy"] }) - .where("iot_device.id = :id", { id: id }) .innerJoinAndSelect( "iot_device.application", "application", @@ -273,10 +290,21 @@ export class IoTDeviceService { "device_model", 'device_model.id = iot_device."deviceModelId"' ) - .orderBy('metadata."sentTime"', "DESC") + .orderBy('metadata."sentTime"', "DESC"); + } + + private async queryDatabaseForIoTDevice(id: number) { + return await this.buildIoTDeviceWithRelationsQuery() + .where("iot_device.id = :id", { id: id }) .getOne(); } + private queryDatabaseForIoTDevices(ids: number[]) { + return this.buildIoTDeviceWithRelationsQuery() + .where("iot_device.id IN (:...ids)", { ids }) + .getMany(); + } + async findGenericHttpDeviceByApiKey(key: string): Promise { return await this.genericHTTPDeviceRepository.findOne({ apiKey: key }); } @@ -292,9 +320,8 @@ export class IoTDeviceService { } async findLoRaWANDeviceByDeviceEUI(deviceEUI: string): Promise { - // TODO: Fix potentiel SQL injection. return await this.loRaWANDeviceRepository.findOne({ - where: `"deviceEUI" ILIKE '${deviceEUI}'`, + deviceEUI: ILike(deviceEUI), }); } @@ -302,20 +329,55 @@ export class IoTDeviceService { createIoTDeviceDto: CreateIoTDeviceDto, userId: number ): Promise { - const childType = iotDeviceTypeMap[createIoTDeviceDto.type]; - const iotDevice = new childType(); + // Reuse the same logic for creating multiple devices. + const iotDevice = await this.createMany([createIoTDeviceDto], userId); + + // We passed in 1 device, so we expect the output to contain 1 device as well + if (iotDevice[0].error) throw new BadRequestException(iotDevice[0].error); + return iotDevice[0].data; + } + + async createMany( + createIoTDeviceDtos: CreateIoTDeviceDto[], + userId: number + ): Promise { + const iotDevicesMaps: CreateIoTDeviceMapDto[] = []; + + // Translate each generic device to the specific type + createIoTDeviceDtos.forEach(createIotDevice => { + try { + const deviceType = iotDeviceTypeMap[createIotDevice.type]; + const iotDevice = new deviceType(); + iotDevicesMaps.push({ iotDeviceDto: createIotDevice, iotDevice }); + } catch (error) { + iotDevicesMaps.push({ iotDeviceDto: createIotDevice, error }); + } + }); - const mappedIotDevice = await this.mapDtoToIoTDevice( - createIoTDeviceDto, - iotDevice, + await this.validateDtoAndCreateIoTDevice( + // Don't process any devices whose type couldn't be determined + filterValidIotDeviceMaps(iotDevicesMaps), false ); + // Filter any device which failed validation or couldn't be created + const validProcessedDevices = filterValidIotDeviceMaps(iotDevicesMaps); - mappedIotDevice.createdBy = userId; - mappedIotDevice.updatedBy = userId; + for (const mappedIotDevice of validProcessedDevices) { + if (mappedIotDevice.iotDevice) { + mappedIotDevice.iotDevice.createdBy = userId; + mappedIotDevice.iotDevice.updatedBy = userId; + } + } + // Store or update valid devices on the database const entityManager = getManager(); - return entityManager.save(mappedIotDevice); + const validIotDevices = validProcessedDevices.map( + iotDeviceMap => iotDeviceMap.iotDevice + ); + const dbIotDevices = await entityManager.save(validIotDevices); + + // Return a new list with all processed and failed devices + return iotDevicesMaps.map(mapAllDevicesByProcessed(dbIotDevices)); } async save(iotDevice: IoTDevice): Promise { @@ -355,24 +417,55 @@ export class IoTDeviceService { userId: number ): Promise { const existingIoTDevice = await this.iotDeviceRepository.findOneOrFail(id); + const iotDeviceDtoMap: CreateIoTDeviceMapDto[] = [ + { iotDevice: existingIoTDevice, iotDeviceDto: updateDto }, + ]; + await this.validateDtoAndCreateIoTDevice(iotDeviceDtoMap, true); - const mappedIoTDevice = await this.mapDtoToIoTDevice( - updateDto, - existingIoTDevice, - true - ); - + const mappedIoTDevice = iotDeviceDtoMap[0].iotDevice; mappedIoTDevice.updatedBy = userId; const res = this.iotDeviceRepository.save(mappedIoTDevice); return res; } + async updateMany( + updateDto: UpdateIoTDeviceBatchDto, + userId: number + ): Promise { + // Fetch existing devices from db and map them + const existingDevices = await this.iotDeviceRepository.findByIds( + updateDto.data.map(device => device.id) + ); + const iotDeviceMaps: CreateIoTDeviceMapDto[] = updateDto.data.map( + updateDevice => ({ + iotDeviceDto: updateDevice, + iotDevice: existingDevices.find( + existingDevice => existingDevice.id === updateDevice.id + ), + }) + ); + await this.validateDtoAndCreateIoTDevice(iotDeviceMaps, true); + + const validDevices = iotDeviceMaps.reduce((res: IoTDevice[], currentMap) => { + if (isValidIoTDeviceMap(currentMap)) { + currentMap.iotDevice.updatedBy = userId; + res.push(currentMap.iotDevice); + } + + return res; + }, []); + const dbIotDevices = await this.iotDeviceRepository.save(validDevices); + + // Return a new list with all processed and failed devices + return iotDeviceMaps.map(mapAllDevicesByProcessed(dbIotDevices)); + } + async delete(device: IoTDevice): Promise { if (device.type == IoTDeviceType.LoRaWAN) { const lorawanDevice = device as LoRaWANDevice; this.logger.debug( - `Deleteing LoRaWANDevice ${lorawanDevice.id} / ${lorawanDevice.deviceEUI} in Chirpstack ...` + `Deleting LoRaWANDevice ${lorawanDevice.id} / ${lorawanDevice.deviceEUI} in Chirpstack ...` ); await this.chirpstackDeviceService.deleteDevice(lorawanDevice.deviceEUI); } @@ -384,90 +477,199 @@ export class IoTDeviceService { return this.iotDeviceRepository.delete(ids); } - private async mapDtoToIoTDevice( - createIoTDeviceDto: CreateIoTDeviceDto, - iotDevice: IoTDevice, + /** + * Validate and map info. from the dto onto an IoT device. This device is then created or updated + * as one of the final steps. I.e. valid chirpstack devices will be created in Chirpstack + * @param iotDeviceMaps + * @param isUpdate + */ + private async validateDtoAndCreateIoTDevice( + iotDeviceMaps: CreateIoTDeviceMapDto[], isUpdate: boolean - ): Promise { - iotDevice.name = createIoTDeviceDto.name; + ): Promise { + const applicationIds = iotDeviceMaps.reduce((res: number[], { iotDeviceDto }) => { + if (iotDeviceDto.applicationId) { + res.push(iotDeviceDto.applicationId); + } - await this.setApplication(createIoTDeviceDto, iotDevice); + return res; + }, []); + // Pre-fetch applications + const applications = await this.getApplicationsByIds(applicationIds); - if (createIoTDeviceDto.longitude != null && createIoTDeviceDto.latitude != null) { - iotDevice.location = { - type: "Point", - coordinates: [createIoTDeviceDto.longitude, createIoTDeviceDto.latitude], - } as Point; - } else { - iotDevice.location = null; - } + // Populate all IoT devices. Any which fail will be added to the response as failed devices + for (const map of iotDeviceMaps) { + const { iotDevice, iotDeviceDto } = map; + try { + const application = applications.find( + app => app.id === iotDeviceDto.applicationId + ); + iotDevice.name = iotDeviceDto.name; + iotDevice.application = application; + + if (iotDeviceDto.longitude != null && iotDeviceDto.latitude != null) { + iotDevice.location = { + type: "Point", + coordinates: [iotDeviceDto.longitude, iotDeviceDto.latitude], + }; + } else { + iotDevice.location = null; + } - iotDevice.comment = createIoTDeviceDto.comment; - iotDevice.commentOnLocation = createIoTDeviceDto.commentOnLocation; - iotDevice.metadata = createIoTDeviceDto.metadata; - iotDevice.deviceModel = await this.mapDeviceModel(iotDevice, createIoTDeviceDto); + iotDevice.comment = iotDeviceDto.comment; + iotDevice.commentOnLocation = iotDeviceDto.commentOnLocation; + iotDevice.metadata = iotDeviceDto.metadata; + } catch (error) { + map.error = error; + } + } - iotDevice = await this.mapChildDtoToIoTDevice( - createIoTDeviceDto, - iotDevice, + // Set and validate properties on each IoT device + // Filter devices whose properties couldn't be set + await this.mapDeviceModels( + filterValidIotDeviceMaps(iotDeviceMaps) + ); + // Filter devices which didn't have a valid device model + await this.mapChildDtoToIoTDevice( + filterValidIotDeviceMaps(iotDeviceMaps), isUpdate ); - - return iotDevice; } - async mapDeviceModel( - iotDevice: IoTDevice, - createIoTDeviceDto: CreateIoTDeviceDto - ): Promise { - if (createIoTDeviceDto.deviceModelId == undefined) { - return null; - } - const deviceModel = await this.deviceModelService.getByIdWithRelations( - createIoTDeviceDto.deviceModelId - ); - const deviceModelOrganisationId = deviceModel.belongsTo.id; - const application = await this.applicationService.findOneWithOrganisation( - iotDevice.application.id + async mapDeviceModels(iotDevicesDtoMap: CreateIoTDeviceMapDto[]): Promise { + // Pre-fetch device models + const deviceModelIds = iotDevicesDtoMap.reduce((ids: number[], dto) => { + if (dto.iotDeviceDto.deviceModelId) { + ids.push(dto.iotDeviceDto.deviceModelId); + } + + return ids; + }, []); + + const deviceModels = await this.deviceModelService.getByIdsWithRelations( + deviceModelIds ); - if (deviceModelOrganisationId != application.belongsTo.id) { - throw new BadRequestException(ErrorCodes.OrganizationDoesNotMatch); - } - return deviceModel; - } + // + const applicationIds = iotDevicesDtoMap.reduce((ids: number[], dto) => { + if (dto.iotDeviceDto.applicationId) { + ids.push(dto.iotDeviceDto.applicationId); + } + return ids; + }, []); + + const applications = await this.applicationService.findManyWithOrganisation( + applicationIds + ); - private async setApplication( - createIoTDeviceDto: CreateIoTDeviceDto, - iotDevice: IoTDevice - ) { - if (createIoTDeviceDto.applicationId != null) { - iotDevice.application = await this.applicationService.findOneWithoutRelations( - createIoTDeviceDto.applicationId + // Ensure that each device model is assignable + for (const map of iotDevicesDtoMap) { + const applicationMatch = applications.find( + application => application.id === map.iotDevice.application.id ); - } else { - iotDevice.application = null; + + if (!applicationMatch) { + map.error = { + message: ErrorCodes.ApplicationDoesNotExist, + }; + continue; + } + + // Validate DeviceModel if set + if (map.iotDeviceDto.deviceModelId) { + + const deviceModelMatch = deviceModels.find( + model => model.id === map.iotDeviceDto.deviceModelId + ); + + if (!deviceModelMatch) { + map.error = { message: ErrorCodes.DeviceModelDoesNotExist }; + continue; + } + + if (deviceModelMatch.belongsTo.id !== applicationMatch.belongsTo.id) { + map.error = { message: ErrorCodes.DeviceModelOrganizationDoesNotMatch }; + continue; + } + + map.iotDevice.deviceModel = deviceModelMatch; + } } } + private async getApplicationsByIds(applicationIds: number[]) { + return applicationIds.length + ? await this.applicationService.findManyByIds(applicationIds) + : []; + } + private async mapChildDtoToIoTDevice( - dto: CreateIoTDeviceDto, - iotDevice: IoTDevice, + iotDevicesDtoMap: CreateIoTDeviceMapDto[], isUpdate: boolean - ): Promise { - if (iotDevice.constructor.name === LoRaWANDevice.name) { - const cast = iotDevice; - const loraDevice = await this.mapLoRaWANDevice(dto, cast, isUpdate); - - return loraDevice; - } else if (iotDevice.constructor.name === SigFoxDevice.name) { - const cast = iotDevice; - const sigfoxDevice = await this.mapSigFoxDevice(dto, cast); + ): Promise { + // Pre-fetch lorawan settings, if any + const loraDeviceEuis = await this.getLorawanDeviceEuis(iotDevicesDtoMap); + const loraOrganizationId = await this.chirpstackDeviceService.getDefaultOrganizationId(); + const loraApplications = await this.chirpstackDeviceService.getAllApplicationsWithPagination( + loraOrganizationId + ); - return sigfoxDevice; + // Populate each IoT device with the specific device type metadata + for (const map of iotDevicesDtoMap) { + try { + if (map.iotDevice.constructor.name === LoRaWANDevice.name) { + const cast = map.iotDevice as LoRaWANDevice; + const loraDevice = await this.mapLoRaWANDevice( + map.iotDeviceDto, + cast, + isUpdate, + loraDeviceEuis, + loraApplications + ); + + map.iotDevice = loraDevice; + } else if (map.iotDevice.constructor.name === SigFoxDevice.name) { + const cast = map.iotDevice as SigFoxDevice; + const sigfoxDevice = await this.mapSigFoxDevice( + map.iotDeviceDto, + cast + ); + + map.iotDevice = sigfoxDevice; + } + } catch (error) { + map.error = { + message: + (error as Error)?.message ?? + ErrorCodes.FailedToCreateOrUpdateIotDevice, + }; + } } + } - return iotDevice; + private async getLorawanDeviceEuis( + iotDevicesDtoMap: CreateIoTDeviceMapDto[] + ): Promise { + const iotLorawanDevices = iotDevicesDtoMap.reduce( + (res: string[], { iotDevice, iotDeviceDto }) => { + if ( + iotDevice.constructor.name === LoRaWANDevice.name && + iotDeviceDto.lorawanSettings + ) { + res.push(iotDeviceDto.lorawanSettings.devEUI); + } + + return res; + }, + [] + ); + + const loraDeviceEuis = !iotLorawanDevices.length + ? [] + : // Fetch from the database instead of from Chirpstack to free up load + await this.ioTLoRaWANDeviceService.getDeviceEUIsByIds(iotLorawanDevices); + + return loraDeviceEuis.map(loraDevice => ({ devEUI: loraDevice.deviceEUI })); } private async mapSigFoxDevice( @@ -646,43 +848,53 @@ export class IoTDeviceService { private async mapLoRaWANDevice( dto: CreateIoTDeviceDto, lorawanDevice: LoRaWANDevice, - isUpdate: boolean + isUpdate: boolean, + lorawanDeviceEuis: ChirpstackDeviceId[] = null, + loraApplications: ListAllChirpstackApplicationsResponseDto = null ): Promise { lorawanDevice.deviceEUI = dto.lorawanSettings.devEUI; if ( !isUpdate && (await this.chirpstackDeviceService.isDeviceAlreadyCreated( - dto.lorawanSettings.devEUI + dto.lorawanSettings.devEUI, + lorawanDeviceEuis )) ) { throw new BadRequestException(ErrorCodes.IdInvalidOrAlreadyInUse); } try { - const chirpstackDeviceDto = await this.chirpstackDeviceService.makeCreateChirpstackDeviceDto( + const chirpstackDeviceDto = this.chirpstackDeviceService.makeCreateChirpstackDeviceDto( dto.lorawanSettings, dto.name ); // Save application const applicationId = await this.chirpstackDeviceService.findOrCreateDefaultApplication( - chirpstackDeviceDto + chirpstackDeviceDto, + loraApplications ); lorawanDevice.chirpstackApplicationId = applicationId; chirpstackDeviceDto.device.applicationID = applicationId.toString(); - await this.chirpstackDeviceService.createOrUpdateDevice(chirpstackDeviceDto); - + // Create or update the LoRa device against Chirpstack API + await this.chirpstackDeviceService.createOrUpdateDevice( + chirpstackDeviceDto, + lorawanDeviceEuis + ); + lorawanDeviceEuis.push(chirpstackDeviceDto.device); await this.doActivation(dto, isUpdate); } catch (err) { this.logger.error(err); + + // This will also be thrown if a chirpstack device with the same name already exists if (err?.response?.data?.error == "object already exists") { throw new BadRequestException(ErrorCodes.NameInvalidOrAlreadyInUse); } + throw err; } - return lorawanDevice; } diff --git a/src/services/device-management/iot-lorawan-device.service.ts b/src/services/device-management/iot-lorawan-device.service.ts new file mode 100644 index 00000000..709cc8c0 --- /dev/null +++ b/src/services/device-management/iot-lorawan-device.service.ts @@ -0,0 +1,22 @@ +import { LoRaWANDeviceId } from "@dto/lorawan-device-id.dto"; +import { LoRaWANDevice } from "@entities/lorawan-device.entity"; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, Repository } from "typeorm"; + +@Injectable() +export class IoTLoRaWANDeviceService { + constructor( + @InjectRepository(LoRaWANDevice) + private iotLorawanDeviceRepository: Repository + ) {} + + private readonly logger = new Logger(IoTLoRaWANDeviceService.name, true); + + public getDeviceEUIsByIds(deviceEUIs: string[]): Promise { + return this.iotLorawanDeviceRepository.find({ + select: ["deviceEUI"], + where: { deviceEUI: In(deviceEUIs) }, + }); + } +} diff --git a/src/services/device-management/multicast.service.ts b/src/services/device-management/multicast.service.ts new file mode 100644 index 00000000..f4b686a3 --- /dev/null +++ b/src/services/device-management/multicast.service.ts @@ -0,0 +1,617 @@ +import { CreateMulticastChirpStackDto } from "@dto/chirpstack/create-multicast-chirpstack.dto"; +import { ListAllMulticastsResponseDto } from "@dto/list-all-multicasts-response.dto"; +import { ListAllMulticastsDto } from "@dto/list-all-multicasts.dto"; +import { Multicast } from "@entities/multicast.entity"; +import { ErrorCodes } from "@enum/error-codes.enum"; +import { + BadRequestException, + forwardRef, + HttpService, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { GenericChirpstackConfigurationService } from "@services/chirpstack/generic-chirpstack-configuration.service"; +import { DeleteResult, getConnection, Repository, SelectQueryBuilder } from "typeorm"; +import { CreateMulticastDto } from "../../entities/dto/create-multicast.dto"; +import { UpdateMulticastDto } from "../../entities/dto/update-multicast.dto"; +import { ApplicationService } from "./application.service"; +import { AxiosResponse } from "axios"; +import { ChirpstackMulticastContentsDto } from "@dto/chirpstack/chirpstack-multicast-contents.dto"; +import { LorawanMulticastDefinition } from "@entities/lorawan-multicast.entity"; +import { IoTDeviceType } from "@enum/device-type.enum"; +import { AddDeviceToMulticastDto } from "@dto/chirpstack-add-device-multicast.dto"; +import { LoRaWANDevice } from "@entities/lorawan-device.entity"; +import { IoTDevice } from "@entities/iot-device.entity"; +import { MulticastDownlinkQueueResponseDto } from "@dto/chirpstack/chirpstack-multicast-downlink-queue-response.dto"; +import { CreateMulticastDownlinkDto } from "@dto/create-multicast-downlink.dto"; +import { + CreateChirpstackMulticastQueueItemDto, + CreateChirpstackMulticastQueueItemResponse, +} from "@dto/chirpstack/create-chirpstack-multicast-queue-item.dto"; +import { ChirpstackDeviceService } from "@services/chirpstack/chirpstack-device.service"; +import { ChirpstackDeviceContentsDto } from "@dto/chirpstack/chirpstack-device-contents.dto"; + +@Injectable() +export class MulticastService extends GenericChirpstackConfigurationService { + constructor( + internalHttpService: HttpService, + + @InjectRepository(Multicast) + private multicastRepository: Repository, + @Inject(forwardRef(() => ApplicationService)) // because of circular reference + private applicationService: ApplicationService, + private chirpStackDeviceService: ChirpstackDeviceService + ) { + super(internalHttpService); + } + private readonly logger = new Logger(MulticastService.name); + multicastGroupUrl = "multicast-groups"; + + async findAndCountAllWithPagination( + // inspired by datatarget and other places in this project. + // Repository syntax doesn't yet support ordering by relation: https://github.com/typeorm/typeorm/issues/2620 + // Therefore we use the QueryBuilder ... + query?: ListAllMulticastsDto, + applicationIds?: number[] + ): Promise { + const orderByColumn = this.getSortingForMulticasts(query); + const direction = query?.sort?.toUpperCase() == "DESC" ? "DESC" : "ASC"; + + let queryBuilder = getConnection() + .getRepository(Multicast) + .createQueryBuilder("multicast") + .innerJoinAndSelect("multicast.application", "application") + .innerJoinAndSelect( + "multicast.lorawanMulticastDefinition", + "lorawan-multicast" + ) + .skip(query?.offset ? +query.offset : 0) + .take(query?.limit ? +query.limit : 100) + .orderBy(orderByColumn, direction); + + // Only apply applicationId filter, if one is given. + queryBuilder = this.filterByApplication(query, queryBuilder, applicationIds); + + const [result, total] = await queryBuilder.getManyAndCount(); + + return { + data: result, + count: total, + }; + } + private getSortingForMulticasts(query: ListAllMulticastsDto) { + let orderBy = `multicast.id`; + if ( + (query?.orderOn != null && query.orderOn == "id") || + query.orderOn == "groupName" + ) { + orderBy = `multicast.${query.orderOn}`; + } + return orderBy; + } + private filterByApplication( + query: ListAllMulticastsDto, + queryBuilder: SelectQueryBuilder, + applicationIds: number[] + ) { + if (query.applicationId) { + queryBuilder = queryBuilder.where("multicast.application = :appId", { + appId: query.applicationId, + }); + } else if (applicationIds) { + queryBuilder = queryBuilder.where( + '"application"."id" IN (:...allowedApplications)', + { + allowedApplications: applicationIds, + } + ); + } + return queryBuilder; + } + + async findOne(id: number): Promise { + return await this.multicastRepository.findOneOrFail(id, { + relations: ["application", "lorawanMulticastDefinition", "iotDevices"], + loadRelationIds: { + relations: ["createdBy", "updatedBy"], + }, + }); + } + + async create( + createMulticastDto: CreateMulticastDto, + userId: number + ): Promise { + //since the multicast is gonna be created in both the DB with relations and in chirpstack, two different objects is gonna be used. + const dbMulticast = new Multicast(); + dbMulticast.lorawanMulticastDefinition = new LorawanMulticastDefinition(); + const chirpStackMulticast = new CreateMulticastChirpStackDto(); + chirpStackMulticast.multicastGroup = new ChirpstackMulticastContentsDto(); + + const mappedDbMulticast = await this.mapMulticastDtoToDbMulticast( + createMulticastDto, + dbMulticast + ); + mappedDbMulticast.createdBy = userId; + mappedDbMulticast.updatedBy = userId; + mappedDbMulticast.lorawanMulticastDefinition.createdBy = userId; + mappedDbMulticast.lorawanMulticastDefinition.updatedBy = userId; + + if (!!createMulticastDto.iotDevices) { + const lorawanDevices = this.checkForLorawan(createMulticastDto); + if (lorawanDevices.length > 0) { + if (await this.checkForDifferentAppID(lorawanDevices)) { + // If they all have same serviceID / appID then proceed. + await this.createMulticastInChirpstack( + createMulticastDto, + chirpStackMulticast, + lorawanDevices, + mappedDbMulticast + ); + } else { + throw new BadRequestException(ErrorCodes.DifferentServiceprofile); + } + } + } + return await this.multicastRepository.save(mappedDbMulticast); + } + + async createMulticastInChirpstack( + createMulticastDto: CreateMulticastDto | UpdateMulticastDto, + chirpStackMulticast: CreateMulticastChirpStackDto, + lorawanDevices: LoRaWANDevice[], + mappedDbMulticast: Multicast + ): Promise { + const mappedChirpStackMulticast = await this.mapMulticastDtoToChirpStackMulticast( + createMulticastDto, + chirpStackMulticast, + lorawanDevices[0] // used for setting appID + ); + + const result = await this.post(this.multicastGroupUrl, mappedChirpStackMulticast); // This creates the multicast in chirpstack. Chirpstack returns an id as a string + + await this.addDevices(createMulticastDto, result); // iotDevices are added to multicast in a seperate endpoint. + + this.handlePossibleError(result, createMulticastDto); + + if (result.status === 200) { + mappedDbMulticast.lorawanMulticastDefinition.chirpstackGroupId = + result.data.id; + } + } + + async update( + existingMulticast: Multicast, + updateMulticastDto: UpdateMulticastDto, + userId: number + ): Promise { + const oldMulticast: Multicast = { ...existingMulticast }; + const mappedMulticast = await this.mapMulticastDtoToDbMulticast( + updateMulticastDto, + existingMulticast + ); + const lorawanDevices = this.checkForLorawan(updateMulticastDto); + const oldLorawanDevices = this.checkForLorawan(oldMulticast); + if (lorawanDevices.length > 0 || oldLorawanDevices.length > 0) { + // check if new lorawan devices is included. If so, either create or update in chirpstack. Otherwise, just update db + if (!existingMulticast.lorawanMulticastDefinition.chirpstackGroupId) { + await this.createIfNotInChirpstack( + lorawanDevices, + updateMulticastDto, + mappedMulticast + ); + } else { + await this.updateLogic( + existingMulticast, + lorawanDevices, + updateMulticastDto, + oldMulticast + ); + } + } + mappedMulticast.updatedBy = userId; + return await this.multicastRepository.save(mappedMulticast); + } + + async updateMulticastToChirpstack( + updateMulticastDto: UpdateMulticastDto, + existingChirpStackMulticast: CreateMulticastChirpStackDto, + lorawanDevices: LoRaWANDevice[], + existingMulticast: Multicast + ): Promise { + const mappedChirpStackMulticast = await this.mapMulticastDtoToChirpStackMulticast( + updateMulticastDto, + existingChirpStackMulticast, + lorawanDevices[0] + ); + + const result = await this.put( + this.multicastGroupUrl, + mappedChirpStackMulticast, + existingMulticast.lorawanMulticastDefinition.chirpstackGroupId + ); + this.handlePossibleError(result, updateMulticastDto); + + const added: IoTDevice[] = []; + const removed: IoTDevice[] = []; + this.compareDevices(existingMulticast, updateMulticastDto, added, removed); + await this.updateDevices( + // add's and removes devices from chirpstack + removed, + added, + existingMulticast.lorawanMulticastDefinition.chirpstackGroupId + ); + } + + checkForLorawan( + multicastDto: CreateMulticastDto | Multicast | UpdateMulticastDto + ): LoRaWANDevice[] { + const lorawanDevices = multicastDto.iotDevices.filter( + x => x.type === IoTDeviceType.LoRaWAN + ) as LoRaWANDevice[]; + return lorawanDevices; + } + + async validateNewDevicesAppID( + chirpStackMulticast: CreateMulticastChirpStackDto, + lorawanDevices: LoRaWANDevice[] + ): Promise { + const devices: ChirpstackDeviceContentsDto[] = []; + + for (let index = 0; index < lorawanDevices.length; index++) { + const lora = await this.chirpStackDeviceService.getChirpstackDevice( + lorawanDevices[index].deviceEUI + ); + devices.push(lora); + } + + for (let i = 0; i < devices.length; i++) { + if ( + devices[i].applicationID !== + chirpStackMulticast.multicastGroup.applicationID + ) { + // if one of the application id is different than the first one, then we know that there is different + // service profiles. Therefore, return false. + return false; + } + } + return true; + } + + async checkForDifferentAppID(lorawanDevices: LoRaWANDevice[]): Promise { + const devices: ChirpstackDeviceContentsDto[] = []; + + for (let index = 0; index < lorawanDevices.length; index++) { + const lora = await this.chirpStackDeviceService.getChirpstackDevice( + lorawanDevices[index].deviceEUI + ); + devices.push(lora); + } + if (devices.length > 0) { + const appID: string = devices[0].applicationID; // In chirpstack, an application is made on each service profile. Because of that, it's enough to + //check on AppID, instead of getting the application and then the service ID + + for (let i = 0; i < devices.length; i++) { + if (devices[i].applicationID !== appID) { + // if one of the application id is different than the first one, then we know that there is different + // service profiles. Therefore, return false. + return false; + } + } + } + return true; // If the appId is equal for each element, then it's the same service profile + } + + async getChirpstackMulticast( + multicastId: string + ): Promise { + const res = await this.get( + `multicast-groups/${multicastId}` + ); + + return res; + } + + async multicastDelete( + id: number, + existingMulticast: Multicast + ): Promise { + const loraDevices = this.checkForLorawan(existingMulticast); + if (loraDevices.length > 0) { + await this.deleteMulticastChirpstack( + existingMulticast.lorawanMulticastDefinition.chirpstackGroupId + ); + } + return this.multicastRepository.delete(id); + } + + async deleteMulticastChirpstack(id: string): Promise { + try { + return await this.delete(this.multicastGroupUrl, id); + } catch (err) { + throw err; + } + } + + private async mapMulticastDtoToDbMulticast( + multicastDto: CreateMulticastDto | UpdateMulticastDto, + multicast: Multicast + ): Promise { + multicast.groupName = multicastDto.name; + multicast.lorawanMulticastDefinition.address = multicastDto.mcAddr; + multicast.lorawanMulticastDefinition.applicationSessionKey = + multicastDto.mcAppSKey; + multicast.lorawanMulticastDefinition.networkSessionKey = multicastDto.mcNwkSKey; + multicast.lorawanMulticastDefinition.dataRate = multicastDto.dr; + multicast.lorawanMulticastDefinition.frameCounter = multicastDto.fCnt; + multicast.lorawanMulticastDefinition.frequency = multicastDto.frequency; + multicast.lorawanMulticastDefinition.groupType = multicastDto.groupType; + multicast.iotDevices = multicastDto.iotDevices; + + if (multicastDto.applicationID !== null) { + try { + multicast.application = await this.applicationService.findOneWithoutRelations( + multicastDto.applicationID + ); + } catch (err) { + this.logger.error( + `Could not find application with id: ${multicastDto.applicationID}` + ); + + throw new BadRequestException(ErrorCodes.IdDoesNotExists); + } + } else { + throw new BadRequestException(ErrorCodes.IdMissing); + } + + return multicast; + } + + private async mapMulticastDtoToChirpStackMulticast( + multicastDto: CreateMulticastDto | UpdateMulticastDto, + multicast: CreateMulticastChirpStackDto, + device: LoRaWANDevice + ): Promise { + multicast.multicastGroup.name = multicastDto.name; + multicast.multicastGroup.mcAddr = multicastDto.mcAddr; + multicast.multicastGroup.mcAppSKey = multicastDto.mcAppSKey; + multicast.multicastGroup.mcNwkSKey = multicastDto.mcNwkSKey; + multicast.multicastGroup.dr = multicastDto.dr; + multicast.multicastGroup.fCnt = multicastDto.fCnt; + multicast.multicastGroup.frequency = multicastDto.frequency; + multicast.multicastGroup.groupType = multicastDto.groupType; + if (!!device) { + // if devices is included, at this point we know that devices is validated. Therefore we can use appID + multicast.multicastGroup.applicationID = device.chirpstackApplicationId.toString(); + } else { + // used for update when all devices are removed + multicast.multicastGroup.applicationID = + multicast.multicastGroup.applicationID; + } + + return multicast; + } + + private handlePossibleError( + result: AxiosResponse, + dto: + | CreateMulticastDto + | UpdateMulticastDto + | CreateChirpstackMulticastQueueItemDto + ): void { + if (result.status !== 200) { + this.logger.error( + `Error from Chirpstack: '${JSON.stringify( + dto + )}', got response: ${JSON.stringify(result.data)}` + ); + throw new BadRequestException({ + success: false, + error: result.data, + }); + } + } + + private async updateDevices( + removed: IoTDevice[], + added: IoTDevice[], + chirpstackMulticastID: string + ) { + removed.forEach(async device => { + // if the removed devices is a lorawan, then delete from chirpstack + if (device.type === IoTDeviceType.LoRaWAN) { + let lorawanDevice: LoRaWANDevice = new LoRaWANDevice(); + lorawanDevice = device as LoRaWANDevice; + return await this.delete( + this.multicastGroupUrl + + "/" + + chirpstackMulticastID + + "/" + + "devices", + lorawanDevice.deviceEUI + ); + } + }); + added.forEach(async device => { + if (device.type === IoTDeviceType.LoRaWAN) { + let lorawanDevice: LoRaWANDevice = new LoRaWANDevice(); + lorawanDevice = device as LoRaWANDevice; + const addDevice = new AddDeviceToMulticastDto(); + addDevice.devEUI = lorawanDevice.deviceEUI; + addDevice.multicastGroupID = chirpstackMulticastID; + + await this.post( + this.multicastGroupUrl + + "/" + + chirpstackMulticastID + + "/" + + "devices", + addDevice + ); + } + }); + } + + private async addDevices( + multicastDto: CreateMulticastDto | UpdateMulticastDto, + chirpstackMulticastID: AxiosResponse // the id returned from chirpstack when the multicast is created in chirpstack. + ) { + multicastDto.iotDevices.forEach(async device => { + if (device.type === IoTDeviceType.LoRaWAN) { + let lorawanDevice: LoRaWANDevice = new LoRaWANDevice(); + lorawanDevice = device as LoRaWANDevice; // cast to LoRaWANDevice since it has DeviceEUI + const addDevice = new AddDeviceToMulticastDto(); + addDevice.devEUI = lorawanDevice.deviceEUI; + addDevice.multicastGroupID = chirpstackMulticastID.data.id; + + await this.post( + // post call to chirpstack + this.multicastGroupUrl + + "/" + + chirpstackMulticastID.data.id + + "/" + + "devices", + addDevice + ); + } + }); + } + + private compareDevices( + oldMulticast: Multicast, + newMulticast: UpdateMulticastDto, + added: IoTDevice[], + removed: IoTDevice[] + ) { + oldMulticast.iotDevices.forEach(dbDevice => { + // if a device in the old multicast is not in the new one, then delete + if ( + newMulticast.iotDevices.findIndex(device => device.id === dbDevice.id) === + -1 + ) { + removed.push(dbDevice); + } + }); + + newMulticast.iotDevices.forEach(frontendDevice => { + // if a device in the new multicast is not in the old one, then add + if ( + oldMulticast.iotDevices.findIndex( + device => device.id === frontendDevice.id + ) === -1 + ) { + added.push(frontendDevice); + } + }); + } + + async getDownlinkQueue( + multicastID: string + ): Promise { + const res = await this.get( + `multicast-groups/${multicastID}/queue` + ); + return res; + } + + public async createDownlink( + dto: CreateMulticastDownlinkDto, + multicast: Multicast + ): Promise { + const csDto: CreateChirpstackMulticastQueueItemDto = { + multicastQueueItem: { + fPort: dto.port, + multicastGroupID: multicast.lorawanMulticastDefinition.chirpstackGroupId, + data: this.hexBytesToBase64(dto.data), + }, + }; + + try { + return this.overwriteDownlink(csDto); + } catch (err) { + this.handlePossibleError(err, csDto); + } + } + async overwriteDownlink( + dto: CreateChirpstackMulticastQueueItemDto + ): Promise { + await this.deleteDownlinkQueue(dto.multicastQueueItem.multicastGroupID); + try { + const res = await this.post( + `multicast-groups/${dto.multicastQueueItem.multicastGroupID}/queue`, + dto + ); + return res.data; + } catch (err) { + const fcntError = + "enqueue downlink payload error: get next downlink fcnt for deveui error"; + if (err?.response?.data?.error?.startsWith(fcntError)) { + throw new BadRequestException( + ErrorCodes.DeviceIsNotActivatedInChirpstack + ); + } + + throw err; + } + } + async deleteDownlinkQueue(multicastID: string): Promise { + await this.delete(`multicast-groups/${multicastID}/queue`); + } + + private hexBytesToBase64(hexBytes: string): string { + return Buffer.from(hexBytes, "hex").toString("base64"); + } + + private async createIfNotInChirpstack( + lorawanDevices: LoRaWANDevice[], + updateMulticastDto: UpdateMulticastDto, + mappedMulticast: Multicast + ): Promise { + const chirpStackMulticast = new CreateMulticastChirpStackDto(); + chirpStackMulticast.multicastGroup = new ChirpstackMulticastContentsDto(); + + if (await this.checkForDifferentAppID(lorawanDevices)) { + await this.createMulticastInChirpstack( + updateMulticastDto, + chirpStackMulticast, + lorawanDevices, + mappedMulticast + ); + } else { + throw new BadRequestException(ErrorCodes.DifferentServiceprofile); + } + } + + private async updateLogic( + existingMulticast: Multicast, + lorawanDevices: LoRaWANDevice[], + updateMulticastDto: UpdateMulticastDto, + oldMulticast: Multicast + ): Promise { + const existingChirpStackMulticast = await this.getChirpstackMulticast( + existingMulticast.lorawanMulticastDefinition.chirpstackGroupId + ); + if (await this.checkForDifferentAppID(lorawanDevices)) { + if ( + await this.validateNewDevicesAppID( + // check if the new devices has the same service profile as the multicast. + existingChirpStackMulticast, + lorawanDevices + ) + ) { + await this.updateMulticastToChirpstack( + updateMulticastDto, + existingChirpStackMulticast, + lorawanDevices, + oldMulticast + ); + } else { + throw new BadRequestException(ErrorCodes.NewDevicesWrongServiceProfile); + } + } else { + throw new BadRequestException(ErrorCodes.DifferentServiceprofile); + } + } +} diff --git a/src/services/user-management/auth.service.ts b/src/services/user-management/auth.service.ts index 114a342f..b01623ca 100644 --- a/src/services/user-management/auth.service.ts +++ b/src/services/user-management/auth.service.ts @@ -1,19 +1,25 @@ -import { Injectable, Logger, UnauthorizedException } from "@nestjs/common"; -import { JwtService } from "@nestjs/jwt"; -import * as bcrypt from "bcryptjs"; -import * as xml2js from "xml2js"; +import configuration from "@config/configuration"; +import { JwtResponseDto } from "@dto/jwt-response.dto"; +import { XMLOutput } from "@dto/user-management/xml-object"; import { UserResponseDto } from "@dto/user-response.dto"; +import { ApiKey } from "@entities/api-key.entity"; import { JwtPayloadDto } from "@entities/dto/internal/jwt-payload.dto"; import { ErrorCodes } from "@entities/enum/error-codes.enum"; -import { UserService } from "./user.service"; +import { Injectable, Logger, UnauthorizedException } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { compare } from "bcryptjs"; import { Profile } from "passport-saml"; -import { JwtResponseDto } from "@dto/jwt-response.dto"; -import { XMLOutput } from "@dto/user-management/xml-object"; -import configuration from "@config/configuration"; +import * as xml2js from "xml2js"; +import { ApiKeyService } from "../api-key-management/api-key.service"; +import { UserService } from "./user.service"; @Injectable() export class AuthService { - constructor(private usersService: UserService, private jwtService: JwtService) { + constructor( + private usersService: UserService, + private jwtService: JwtService, + private apiKeyService: ApiKeyService + ) { this.KOMBIT_ROLE_URI = configuration()["kombit"]["roleUri"]; } private readonly logger = new Logger(AuthService.name); @@ -26,7 +32,7 @@ export class AuthService { throw new UnauthorizedException(ErrorCodes.UserInactive); } - const res = await bcrypt.compare(password, user.passwordHash); + const res = await compare(password, user.passwordHash); if (res === true) { await this.usersService.updateLastLoginToNow(user); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -138,4 +144,14 @@ export class AuthService { accessToken: this.jwtService.sign(payload), }; } + + async validateApiKey(apiKey: string): Promise { + const apiKeyDb = await this.apiKeyService.findOne(apiKey); + + if (!apiKeyDb) { + this.logger.warn(`Login with API key: Key not found`); + } + + return apiKeyDb; + } } diff --git a/src/services/user-management/organization.service.ts b/src/services/user-management/organization.service.ts index 4f9d858a..b219dfa5 100644 --- a/src/services/user-management/organization.service.ts +++ b/src/services/user-management/organization.service.ts @@ -1,15 +1,5 @@ -import { - BadRequestException, - Inject, - Injectable, - Logger, - forwardRef, - NotFoundException, -} from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { In, Repository } from "typeorm"; - import { DeleteResponseDto } from "@dto/delete-application-response.dto"; +import { ListAllEntitiesDto } from "@dto/list-all-entities.dto"; import { ListAllMinimalOrganizationsResponseDto, ListAllOrganizationsResponseDto, @@ -18,9 +8,17 @@ import { CreateOrganizationDto } from "@dto/user-management/create-organization. import { UpdateOrganizationDto } from "@dto/user-management/update-organization.dto"; import { Organization } from "@entities/organization.entity"; import { ErrorCodes } from "@enum/error-codes.enum"; - +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, Repository } from "typeorm"; import { PermissionService } from "./permission.service"; -import { ListAllEntitiesDto } from "@dto/list-all-entities.dto"; @Injectable() export class OrganizationService { @@ -158,6 +156,14 @@ export class OrganizationService { }); } + findByPermissionIds(permissionIds: number[]): Promise { + return this.organizationRepository + .createQueryBuilder("organization") + .innerJoin("organization.permissions", "perm") + .where("perm.id IN (:...permissionIds)", { permissionIds }) + .getMany(); + } + async delete(id: number): Promise { const res = await this.organizationRepository.delete(id); if (res.affected == 0) { diff --git a/src/services/user-management/permission.service.ts b/src/services/user-management/permission.service.ts index e8e2c2f0..cb54e595 100644 --- a/src/services/user-management/permission.service.ts +++ b/src/services/user-management/permission.service.ts @@ -230,11 +230,11 @@ export class PermissionService { .skip(query?.offset ? +query.offset : 0) .orderBy(orderBy, order); - if (query.userId) { + if (query?.userId) { qb = qb.where("user.id = :userId", { userId: +query.userId }); } else if (orgs) { qb.where({ organization: In(orgs) }); - } else if (query.organisationId) { + } else if (query?.organisationId) { qb = qb.where("org.id = :orgId", { orgId: +query.organisationId }); } @@ -246,10 +246,10 @@ export class PermissionService { }; } - private getSorting(query: ListAllPermissionsDto) { + private getSorting(query: ListAllPermissionsDto | undefined) { let orderBy = `permission.id`; if ( - query.orderOn != null && + query?.orderOn != null && (query.orderOn == "id" || query.orderOn == "name" || query.orderOn == "type" || @@ -281,10 +281,9 @@ export class PermissionService { }); } - async findPermissionsForUser(userId: number): Promise { - return await this.permissionRepository + buildPermissionsQuery(): SelectQueryBuilder { + return this.permissionRepository .createQueryBuilder("permission") - .leftJoin("permission.users", "user") .leftJoinAndSelect( "application_permissions_permission", "application_permission", @@ -295,32 +294,60 @@ export class PermissionService { "application", '"application"."id"="application_permission"."applicationId" ' ) - .where("user.id = :id", { id: userId }) .select([ "permission.type as permission_type", "permission.organization as organization_id", "application.id as application_id", - ]) + ]); + } + + async findPermissionsForUser(userId: number): Promise { + return await this.buildPermissionsQuery() + .leftJoin("permission.users", "user") + .where("user.id = :id", { id: userId }) + .getRawMany(); + } + + async findPermissionsForApiKey(apiKeyId: number): Promise { + return await this.buildPermissionsQuery() + .leftJoin("permission.apiKeys", "apiKey") + .where("apiKey.id = :id", { id: apiKeyId }) .getRawMany(); } async findPermissionsForOrgAdminWithApplications( userId: number ): Promise { - return await this.permissionRepository - .createQueryBuilder("permission") + return await this.buildPermissionsWithApplicationsQuery() .leftJoin("permission.users", "user") - .leftJoinAndSelect("permission.organization", "organization") - .leftJoinAndSelect("organization.applications", "application") .where("permission.type = :permType AND user.id = :id", { permType: PermissionType.OrganizationAdmin, id: userId, }) + .getRawMany(); + } + + buildPermissionsWithApplicationsQuery(): SelectQueryBuilder { + return this.permissionRepository + .createQueryBuilder("permission") + .leftJoinAndSelect("permission.organization", "organization") + .leftJoinAndSelect("organization.applications", "application") .select([ "permission.type as permission_type", "permission.organization as organization_id", "application.id as application_id", - ]) + ]); + } + + async findPermissionsForApiKeyOrgAdminWithApplications( + apiKeyId: number + ): Promise { + return await this.buildPermissionsWithApplicationsQuery() + .leftJoin("permission.apiKeys", "apiKey") + .where("permission.type = :permType AND apiKey.id = :id", { + permType: PermissionType.OrganizationAdmin, + id: apiKeyId, + }) .getRawMany(); } @@ -334,6 +361,26 @@ export class PermissionService { permissions = _.union(permissions, permissionsForOrgAdmin); } + return this.createUserPermissionsFromPermissions(permissions); + } + + async findPermissionGroupedByLevelForApiKey( + apiKeyId: number + ): Promise { + let permissions = await this.findPermissionsForApiKey(apiKeyId); + if (this.isOrganizationAdmin(permissions)) { + // For organization admins, we need to fetch all applications they have permissions to + const permissionsForOrgAdmin = await this.findPermissionsForApiKeyOrgAdminWithApplications( + apiKeyId + ); + permissions = _.union(permissions, permissionsForOrgAdmin); + } + return this.createUserPermissionsFromPermissions(permissions); + } + + private createUserPermissionsFromPermissions( + permissions: PermissionMinimalDto[] + ): UserPermissions { const res = new UserPermissions(); permissions.forEach(p => { @@ -353,6 +400,10 @@ export class PermissionService { return res; } + async findManyByIds(ids: number[]): Promise { + return await this.permissionRepository.findByIds(ids); + } + private isOrganizationAdmin(permissions: PermissionMinimalDto[]) { return permissions.some( x => x.permission_type == PermissionType.OrganizationAdmin diff --git a/src/services/user-management/user.service.ts b/src/services/user-management/user.service.ts index 36f5b0a3..8bc1b583 100644 --- a/src/services/user-management/user.service.ts +++ b/src/services/user-management/user.service.ts @@ -256,6 +256,9 @@ export class UserService { take: +query.limit, skip: +query.offset, order: sorting, + where: { + isSystemUser: false + } }); return { diff --git a/test/unit/fiware-data-target.service.spec.ts b/test/unit/fiware-data-target.service.spec.ts new file mode 100644 index 00000000..b357c820 --- /dev/null +++ b/test/unit/fiware-data-target.service.spec.ts @@ -0,0 +1,73 @@ +import { HttpService } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; + +import { AuthorizationType } from "@enum/authorization-type.enum"; +import { HttpPushDataTargetData } from "@interfaces/http-push-data-target-data.interface"; +import { FiwareDataTargetService } from "@services/data-targets/fiware-data-target.service"; +import { FiwareDataTargetConfiguration } from "@interfaces/fiware-data-target-configuration.interface"; + +describe("FiwareDataTargetService", () => { + let service: FiwareDataTargetService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FiwareDataTargetService, + { + provide: HttpService, + useValue: { + post: jest.fn().mockResolvedValue([{}]), + }, + }, + ], + }).compile(); + + service = module.get(FiwareDataTargetService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + it("check headers without context with authorizationHeader", () => { + const config: FiwareDataTargetConfiguration = { + url: "http://example.com/endpoint", + timeout: 1337, + authorizationType: AuthorizationType.HEADER_BASED_AUTHORIZATION, + authorizationHeader: "Bearer AbCdEf123456", + }; + + const res = service.makeAxiosConfiguration(config); + + expect(res).toMatchObject({ + timeout: 1337, + headers: { + "Content-Type": "application/ld+json", + Authorization: "Bearer AbCdEf123456", + }, + }); + }); + + it("check headers with context and tenant", () => { + const config: FiwareDataTargetConfiguration = { + url: "http://example.com/endpoint", + timeout: 0, + authorizationType: AuthorizationType.NO_AUTHORIZATION, + context: "http://contextfile.json", + tenant: "Test" + }; + + const res = service.makeAxiosConfiguration(config); + + expect(res).toMatchObject({ + timeout: 0, + headers: { + "Content-Type": "application/json", + "Link": '; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"', + "NGSILD-Tenant": 'Test', + }, + }); + }); + + +});