テスト駆動で学ぶ Firestore セキュリティルール【カスタム関数編:第4回】
IT技術
【第4回】テスト駆動で成績データコレクションのルールを実装する
前回の記事では、成績コレクション records のデータ更新ルールを実装しつつ、ルールの見通しをよくするため、カスタム関数を使ってルールを整理してみました。
今回は、成績コレクション records のデータ取得・削除用ルールの実装を進めていきます。
データ取得・削除用ルールの作成後、最後に、これまでに作成した全てのテストを実行して、作成したセキュリティルールが第1回に設定した要件に沿ったルールとなっているかを確認します。
前回の記事はこちら
前回の記事は、以下をご参照ください。
Firestore 上のデータ取得ルールの実装について
テストの作成
ここまでの実装と同様に、まずは、データ取得の要件を再確認します。
- admin ユーザはデータの取得可
teacher ユーザの取得要件
- 担任でないデータは取得不可
student ユーザの取得要件
- 自分以外のデータは取得不可
- 対象シーズンが成績評価期間の場合は取得不可
上記の要件に沿って、テストを作成します。
tests/data.read.test.ts ファイルを追加して、以下のようにコードを記述してください。
テストのコード
1process.env.FIRESTORE_EMULATOR_HOST = "localhost:58080";
2
3import { FirestoreTestSupporter } from "firestore-test-supporter";
4import * as path from "path";
5import * as firebase from "@firebase/testing";
6import { users } from "./data/collections/users";
7import { records } from "./data/collections/records";
8import { seasons } from "./data/collections/seasons";
9import InitialData from "./data/InitialData";
10
11describe("成績データの取得テスト", () => {
12 const rulesFilePath = path.join(__dirname, "firestore.rules");
13 const supporter = new FirestoreTestSupporter("my-test-project", rulesFilePath);
14
15 beforeEach(async () => {
16 await supporter.loadRules();
17
18 // 各コレクションに初期データを追加
19 const initialData = new InitialData(rulesFilePath);
20 await initialData.setupUsers();
21 await initialData.setupRecords();
22 await initialData.setupSeasons();
23 });
24
25 afterEach(async () => {
26 await supporter.cleanup()
27 });
28
29 test("adminユーザはデータの取得可", async () => {
30 // adminユーザで認証されたクライアントを取得
31 const db = supporter.getFirestoreWithAuth(users.sample.admin);
32
33 const doc = db.collection(records.collectionPath).doc(records.initialData.id);
34 await firebase.assertSucceeds(doc.get());
35 });
36
37 test("ログインしていないユーザは取得不可", async () => {
38 // 認証されていないクライアントを取得
39 const db = supporter.getFirestore();
40
41 const doc = db.collection(records.collectionPath).doc(records.initialData.id);
42 await firebase.assertFails(doc.get());
43 });
44
45 describe("teacherユーザの取得要件", () => {
46 let doc: firebase.firestore.DocumentReference;
47 beforeEach(() => {
48 // teacherユーザで認証されたクライアントを取得
49 const db = supporter.getFirestoreWithAuth(users.sample.teacher);
50 doc = db.collection(records.collectionPath).doc(records.initialData.id);
51 });
52
53 test('要件にあったデータの取得に成功', async () => {
54 await firebase.assertSucceeds(doc.get());
55 });
56
57 test("担任でないデータは取得不可", async () => {
58 // データベース上のデータの担任を変更
59 const dbWithAdmin = supporter.getAdminFirestore();
60 const docWithAdmin = dbWithAdmin.collection(records.collectionPath).doc(records.initialData.id);
61 const newData = { ...records.validUpdateDataForTeacher, homeroomTeacher: "other_teacher" };
62 await firebase.assertSucceeds(docWithAdmin.update(newData));
63
64 await firebase.assertFails(doc.get());
65 })
66 });
67
68 describe("studentユーザの取得要件", () => {
69 test('要件にあったデータの取得に成功', async () => {
70 // データベース上のデータの成績評価期間フラグをfalseに変更
71 const dbWithAdmin = supporter.getAdminFirestore();
72 const seasonDoc = dbWithAdmin.collection(seasons.collectionPath).doc(seasons.initialData(true).id);
73 await firebase.assertSucceeds(seasonDoc.update(seasons.initialData(false)));
74
75 // studentユーザで認証されたクライアントを取得
76 const db = supporter.getFirestoreWithAuth(users.sample.student);
77
78 const doc = db.collection(records.collectionPath).doc(records.initialData.id);
79 await firebase.assertSucceeds(doc.get());
80 });
81
82 test("自分以外のデータは取得不可", async () => {
83 // データベース上のデータの成績評価期間フラグをfalseに変更
84 const dbWithAdmin = supporter.getAdminFirestore();
85 const seasonDoc = dbWithAdmin.collection(seasons.collectionPath).doc(seasons.initialData(true).id);
86 await firebase.assertSucceeds(seasonDoc.update(seasons.initialData(false)));
87
88 // 成績データの生徒と異なる生徒で認証されたクライアントを取得
89 const db = supporter.getFirestoreWithAuth(users.sample.other_student);
90
91 const doc = db.collection(records.collectionPath).doc(records.initialData.id);
92 await firebase.assertFails(doc.get());
93 });
94
95 test("対象シーズンが成績評価期間の場合は取得不可", async () => {
96 // studentユーザで認証されたクライアントを取得
97 const db = supporter.getFirestoreWithAuth(users.sample.student);
98
99 const doc = db.collection(records.collectionPath).doc(records.initialData.id);
100 await firebase.assertFails(doc.get());
101 })
102 })
103});
テストの概要
各テストで共通の内容
テストごとに、テスト対象となるロールユーザで認証されたクライアントを取得しています。
teacher ユーザの取得要件のテスト
担任でないデータは取得不可 となるテストでは、データ取得前に、データベース上のデータの担任フィールドを、リクエストデータの担任とは異なる担任に変更。
student ユーザの取得要件のテスト
要件にあったデータの取得に成功 となるテストと、自分以外のデータは取得不可 となるテストでは、データベース上のデータの成績評価期間フラグを「false」に変更して、成績評価期間外に設定しています。
Firestore 上のデータ取得ルールを実装してみる
それでは、作成したテストに沿って、セキュリティルールを実装していきましょう。
全ての read アクセスを許可するルールを設定
データ更新ルールの実装のときと同じように、今回もまず、データ取得に関するルールだけを実装し、最後に、作成した全てのルールと統合したいと思います。
まずは、records コレクションの read アクセスを全て許可し、「要件にあったデータの追加に成功」するテストだけを通します。
以下のように、セキュリティルールを調整してください。
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 match /students/{studentId}/records/{recordId}{
5 // 全てのreadアクセスを許可
6 allow read: if true;
7 }
8 }
9}
テストをスタート
以下のコマンドを実行して、データ取得ルール用のテストをスタートしてください。
1npm run test-watch tests/data.read.test.ts
テスト結果
1 FAIL tests/data.read.test.ts (5.181s)
2 成績データの取得テスト
3 √ adminユーザはデータの取得可 (2150ms)
4 × ログインしていないユーザは取得不可 (145ms)
5 teacherユーザの取得要件
6 √ 要件にあったデータの取得に成功 (86ms)
7 × 担任でないデータは取得不可 (109ms)
8 studentユーザの取得要件
9 √ 要件にあったデータの取得に成功 (109ms)
10 × 自分以外のデータは取得不可 (100ms)
11 × 対象シーズンが成績評価期間の場合は取得不可 (74ms)
adminユーザはデータの取得可 となるテストと、teacher ユーザ、student ユーザの 要件にあったデータの取得に成功 するテストが成功しました。
これまで同様に、失敗したテストを順次、通していきます。
「ログインしていないユーザは取得不可」となるルールを設定
ログインしていないユーザは取得不可 となるよう、以下のようにルールを調整してください。
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 match /students/{studentId}/records/{recordId}{
5 function isLoggedIn(){
6 return request.auth.uid != null;
7 }
8
9 allow read: if isLoggedIn();
10 }
11 }
12}
テスト結果
1 FAIL tests/data.read.test.ts
2 成績データの取得テスト
3 √ adminユーザはデータの取得可 (97ms)
4 √ ログインしていないユーザは取得不可 (117ms)
5 teacherユーザの取得要件
6 √ 要件にあったデータの取得に成功 (1174ms)
7 × 担任でないデータは取得不可 (134ms)
8 studentユーザの取得要件
9 √ 要件にあったデータの取得に成功 (115ms)
10 × 自分以外のデータは取得不可 (104ms)
11 × 対象シーズンが成績評価期間の場合は取得不可 (82ms)
ログインしていないユーザは取得不可 となるテストが通りました。
認証チェックをロールごとに分割
ここで、admin、teacher、student ユーザ、それぞれのアクセス許可条件を個別に設定するため、認証チェックをロールごとに分割します。
以下のように、セキュリティルールを調整してください。
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 match /students/{studentId}/records/{recordId}{
5 function isLoggedIn(){
6 return request.auth.uid != null;
7 }
8
9 function isUserRole(role){
10 return isLoggedIn()
11 && role in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles;
12 }
13
14 function isAdmin(){
15 return isUserRole("admin");
16 }
17
18 function isTeacher(){
19 return isUserRole("teacher");
20 }
21
22 // studentロールチェック
23 function isStudent(){
24 return isUserRole("student");
25 }
26
27 allow read: if isAdmin();
28
29 allow read: if isTeacher();
30
31 allow read: if isStudent();
32 }
33 }
34}
データ追加・更新ルールの実装で定義した isAdmin()、isTeacher() 関数と同様、student ロールのチェック関数 isStudent() を定義しています。
ここまでに定義した、ロールチェック用のカスタム関数を使って、認証チェックをロールごとに分割しています。
ルールの調整による影響がないか確認するため、テスト結果を確認しておきます。
テスト結果
1 FAIL tests/data.read.test.ts
2 成績データの取得テスト
3 √ adminユーザはデータの取得可 (1203ms)
4 √ ログインしていないユーザは取得不可 (111ms)
5 teacherユーザの取得要件
6 √ 要件にあったデータの取得に成功 (75ms)
7 × 担任でないデータは取得不可 (104ms)
8 studentユーザの取得要件
9 √ 要件にあったデータの取得に成功 (92ms)
10 × 自分以外のデータは取得不可 (92ms)
11 × 対象シーズンが成績評価期間の場合は取得不可 (62ms)
テスト結果に変化はないので、続いて teacherユーザの取得要件 テストを通していきます。
「担任でないデータは取得不可」となるルールを設定
担任でないデータは取得不可 となるように、セキュリティルールを以下のように調整してください。
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 match /students/{studentId}/records/{recordId}{
5 function isLoggedIn(){
6 return request.auth.uid != null;
7 }
8
9 function isUserRole(role){
10 return isLoggedIn()
11 && role in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles;
12 }
13
14 function isAdmin(){
15 return isUserRole("admin");
16 }
17
18 function isTeacher(){
19 return isUserRole("teacher");
20 }
21
22 function isStudent(){
23 return isUserRole("student");
24 }
25
26 function isHomeroomTeacher(){
27 return request.auth.uid == resource.data.homeroomTeacher
28 }
29
30 allow read: if isAdmin();
31
32 allow read: if isTeacher()
33 && isHomeroomTeacher();
34
35 allow read: if isStudent();
36 }
37 }
38}
ここで、teacher ユーザに対する許可条件に、更新ルールの実装時に定義した isHomeroomTeacher() 関数を追加して、担任チェックを行っています。
テスト結果
1 FAIL tests/data.read.test.ts
2 成績データの取得テスト
3 √ adminユーザはデータの取得可 (2124ms)
4 √ ログインしていないユーザは取得不可 (125ms)
5 teacherユーザの取得要件
6 √ 要件にあったデータの取得に成功 (74ms)
7 √ 担任でないデータは取得不可 (95ms)
8 studentユーザの取得要件
9 √ 要件にあったデータの取得に成功 (94ms)
10 × 自分以外のデータは取得不可 (102ms)
11 × 対象シーズンが成績評価期間の場合は取得不可 (75ms)
teacherユーザの取得要件 テストが全て通りました。
student ユーザの取得要件をルールに設定
続いて、studentユーザの取得要件 テストを通していきます。
以下のように、ルールを調整してください。
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 match /students/{studentId}/records/{recordId}{
5 function isLoggedIn(){
6 return request.auth.uid != null;
7 }
8
9 function isUserRole(role){
10 return isLoggedIn()
11 && role in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles;
12 }
13
14 function isAdmin(){
15 return isUserRole("admin");
16 }
17
18 function isTeacher(){
19 return isUserRole("teacher");
20 }
21
22 function isStudent(){
23 return isUserRole("student");
24 }
25
26 function isHomeroomTeacher(){
27 return request.auth.uid == resource.data.homeroomTeacher
28 }
29
30 function isEvaluationPeriod(){
31 return get(/databases/$(database)/documents/seasons/$(resource.data.season)).data.evaluationPeriod
32 }
33
34 // 本人データチェック
35 function isSelfData(){
36 return request.auth.uid == studentId
37 }
38
39 allow read: if isAdmin();
40
41 allow read: if isTeacher()
42 && isHomeroomTeacher();
43
44 allow read: if isStudent()
45 && isSelfData()
46 && !isEvaluationPeriod();
47 }
48 }
49}
ここで、student ユーザの許可条件に、本人データチェック関数 isSelfData() と、データ更新ルールの実装時に定義した成績評価期間チェック関数 isEvaluationPeriod() を追加しています。
isSelfData() 関数では、リクエストユーザと「match ステートメント」中の studentId 変数が一致するかをチェックし、リクエストユーザ本人のデータかどうかを確認しています。
成績評価期間のチェックでは、isEvaluationPeriod() に論理否定演算子「!」を添えて、評価期間外の取得を許可しています。
テスト結果
1 PASS tests/data.read.test.ts
2 成績データの取得テスト
3 √ adminユーザはデータの取得可 (2134ms)
4 √ ログインしていないユーザは取得不可 (117ms)
5 teacherユーザの取得要件
6 √ 要件にあったデータの取得に成功 (95ms)
7 √ 担任でないデータは取得不可 (99ms)
8 studentユーザの取得要件
9 √ 要件にあったデータの取得に成功 (97ms)
10 √ 自分以外のデータは取得不可 (95ms)
11 √ 対象シーズンが成績評価期間の場合は取得不可 (63ms)
全てのテストが通りました!
以上で、データ取得用のルールは実装完了です。
次のテストとの競合を避けるため、テストを終了してください。
Firestore 上のデータ削除ルールの実装について
テストの作成
次に、データ削除ルールを実装します。
ここまでのルール実装と同様、テストを作成する前に、まずはデータ削除の要件を再確認します。
- admin ユーザ以外は削除不可
上記の要件に沿って、テストを作成します。
tests/data.delete.test.ts ファイルを追加して、以下のようにコードを記述してください。
テストのコード
1process.env.FIRESTORE_EMULATOR_HOST = "localhost:58080";
2
3import { FirestoreTestSupporter } from "firestore-test-supporter";
4import * as path from "path";
5import * as firebase from "@firebase/testing";
6import { users } from "./data/collections/users";
7import { records } from "./data/collections/records";
8import InitialData from "./data/InitialData";
9
10describe("成績データの削除テスト", () => {
11 const rulesFilePath = path.join(__dirname, "firestore.rules");
12 const supporter = new FirestoreTestSupporter("my-test-project", rulesFilePath);
13
14 beforeEach(async () => {
15 await supporter.loadRules();
16
17 // 各コレクションに初期データを追加
18 const initialData = new InitialData(rulesFilePath);
19 await initialData.setupUsers();
20 await initialData.setupRecords();
21 await initialData.setupSeasons();
22 });
23
24 afterEach(async () => {
25 await supporter.cleanup()
26 });
27
28 describe("adminユーザ以外は削除不可", () => {
29 test("ログインしていないユーザはデータの削除不可", async () => {
30 // 認証されていないクライアントを取得
31 const db = supporter.getFirestore();
32
33 const doc = db.collection(records.collectionPath).doc(records.initialData.id);
34 await firebase.assertFails(doc.delete());
35 });
36
37 test("adminユーザはデータの削除に成功する", async () => {
38 // adminユーザで認証されたクライアントを取得
39 const db = supporter.getFirestoreWithAuth(users.sample.admin);
40
41 const doc = db.collection(records.collectionPath).doc(records.initialData.id);
42 await firebase.assertSucceeds(doc.delete());
43 });
44
45 test("teacherユーザはデータの削除不可", async () => {
46 // teacherユーザで認証されたクライアントを取得
47 const db = supporter.getFirestoreWithAuth(users.sample.teacher);
48
49 const doc = db.collection(records.collectionPath).doc(records.initialData.id);
50 await firebase.assertFails(doc.delete());
51 });
52
53 test("studentユーザはデータの削除不可", async () => {
54 // studentユーザで認証されたクライアントを取得
55 const db = supporter.getFirestoreWithAuth(users.sample.student);
56
57 const doc = db.collection(records.collectionPath).doc(records.initialData.id);
58 await firebase.assertFails(doc.delete());
59 })
60 })
61});
テストの概要
認証されていないユーザ、teacher ユーザ、student ユーザのそれぞれについて、データの削除不可テストを行います。
admin ユーザについては、要件に沿って削除が成功するかどうかをチェックします。
Firestore 上のデータ削除ルールを実装してみる
続いて、作成したテストに沿って、セキュリティルールを実装していきます。
ここまでのルール実装と同様、データ削除に関する部分だけを実装し、その後、これまでに作成したルールと統合します。
全ての delete アクセスを許可するルールを設定
削除ルールについては、簡単にルールを調整する程度なので、最初にルールを作成し、続いて、テストをパスするかを簡単に確認します。
以下のように、セキュリティルールを調整してください。
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 match /students/{studentId}/records/{recordId}{
5 function isLoggedIn(){
6 return request.auth.uid != null;
7 }
8
9 function isUserRole(role){
10 return isLoggedIn()
11 && role in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles;
12 }
13
14 function isAdmin(){
15 return isUserRole("admin");
16 }
17
18 allow delete: if isAdmin();
19 }
20 }
21}
データ追加ルールの実装で定義した isAdmin() 関数を使って、admin ユーザのみデータの削除を許可しています。
テストをスタート
以下のコマンドを実行して、データ取得ルール用のテストをスタートしてください。
1npm run test tests/data.delete.test.ts
テスト結果
1 PASS tests/data.delete.test.ts
2 成績データの削除テスト
3 adminユーザ以外は削除不可
4 √ ログインしていないユーザはデータの削除不可 (2210ms)
5 √ adminユーザはデータの削除に成功する (124ms)
6 √ teacherユーザはデータの削除不可 (112ms)
7 √ studentユーザはデータの削除不可 (113ms)
全てのテストが通りましたので、以上でデータ削除用のルールは実装完了です。
リグレッションテスト
最後に、これまでの記事で実装したルールを統合し、全てのテストを再度実行します。
テストにより、ルールの調整を進める中で「ルールが要件から外れていないか?」確認します。
前回までに実装したルールへ、今回作成したデータの取得・削除ルールを統合すると以下のようになります。
全てのルールを統合してテストを再実行
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 match /students/{studentId}/records/{recordId}{
5 function isLoggedIn(){
6 return request.auth.uid != null;
7 }
8
9 function isUserRole(role){
10 return isLoggedIn()
11 && role in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles;
12 }
13
14 function isAdmin(){
15 return isUserRole("admin");
16 }
17
18 function isTeacher(){
19 return isUserRole("teacher");
20 }
21
22 function isStudent(){
23 return isUserRole("student");
24 }
25
26 function isRecordEmpty(_resource){
27 return _resource.data.record == {}
28 }
29
30 function isValidFormat(){
31 return request.resource.data.id is string
32 && request.resource.data.studentId is string
33 && request.resource.data.name is string
34 && request.resource.data.season is string
35 && request.resource.data.homeroomTeacher is string
36 && request.resource.data.record is map
37 }
38
39 function isNotUpdate(key){
40 return request.resource.data[key] == resource.data[key]
41 }
42
43 function isNotUpdateImmutables(){
44 return isNotUpdate("id")
45 && isNotUpdate("studentId")
46 && isNotUpdate("season")
47 }
48
49 function isHomeroomTeacher(){
50 return request.auth.uid == resource.data.homeroomTeacher
51 }
52
53 function isEvaluationPeriod(){
54 return get(/databases/$(database)/documents/seasons/$(resource.data.season)).data.evaluationPeriod
55 }
56
57 function isSelfData(){
58 return request.auth.uid == studentId
59 }
60
61 allow create: if isAdmin()
62 && request.resource.data.record == {}
63 && isValidFormat();
64
65 allow update: if isAdmin()
66 && isNotUpdateImmutables()
67 && isNotUpdate("record")
68 && isValidFormat();
69
70 allow update: if isTeacher()
71 && isHomeroomTeacher()
72 && isNotUpdateImmutables()
73 && isNotUpdate("name")
74 && isNotUpdate("homeroomTeacher")
75 && isEvaluationPeriod()
76 && isValidFormat();
77
78 allow read: if isAdmin();
79
80 allow read: if isTeacher()
81 && isHomeroomTeacher();
82
83 allow read: if isStudent()
84 && isSelfData()
85 && !isEvaluationPeriod();
86
87 allow delete: if isAdmin();
88 }
89 }
90}
全てのテストを実行
以下のコマンドを実行して、ここまでに追加した全てのテストを実行してください。
1npm run test
テスト結果
1 PASS tests/data.update.test.ts (5.817s)
2 PASS tests/data.read.test.ts
3 PASS tests/data.delete.test.ts
4 PASS tests/data.create.test.ts
5
6Test Suites: 4 passed, 4 total
7Tests: 32 passed, 32 total
8Snapshots: 0 total
9Time: 10.156s, estimated 12s
10Ran all test suites.
全てのテストに成功しました。以上で、最初に設定した要件に沿ったルールを実装することができました。
まとめ
以上、成績評価システムを例に、成績コレクションのルールを実装すると共に、セキュリティルールでのカスタム関数の利用について解説してみました。
最後に、今回の内容を簡単にまとめてみたいと思います。
- カスタム関数で条件に名前付け!
- カスタム関数で重複回避!
- カスタム関数で条件が再利用できる!
- カスタム関数でセキュリティルールがスッキリ!
- 締めのリグレッションテスト!
これにて、『カスタム関数編』は終了となります!
4回にわたり、ご愛読いただきありがとうございます!
次回の「テスト駆動で学ぶ Firestore セキュリティルール」の連載にもご期待ください!
こちらの記事もオススメ!
2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...
2020.08.04エンジニアの働き方 特集社員としての働き方社員としてのエンジニアの働き方とは?ライトコードのエンジニアはどんな働き方をしてるのか、まとめたいと...
2020.07.27IT・コンピューターの歴史特集IT・コンピューターの歴史をまとめていきたいと思います!弊社ブログにある記事のみで構成しているため、まだ「未完成状態」...
データ検証編はこちら
「データ検証編」では、リクエストデータ、データベース上のデータの参照などについて解説
データ比較編はこちら
「データ比較編」では、データの比較や型チェックなどについて解説
カスタム関数編はこちら
「カスタム関数編」では、「比較編」で作成したルールをもとに、カスタム関数の使い方を解説
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
「好きを仕事にするエンジニア集団」の(株)ライトコードです! ライトコードは、福岡、東京、大阪、名古屋の4拠点で事業展開するIT企業です。 現在は、国内を代表する大手IT企業を取引先にもち、ITシステムの受託事業が中心。 いずれも直取引で、月間PV数1億を超えるWebサービスのシステム開発・運営、インフラの構築・運用に携わっています。 システム開発依頼・お見積もり大歓迎! また、現在「WEBエンジニア」「モバイルエンジニア」「営業」「WEBデザイナー」を積極採用中です! インターンや新卒採用も行っております。 以下よりご応募をお待ちしております! https://rightcode.co.jp/recruit
おすすめ記事
immichを知ってほしい
2024.10.31